Files
tatlock-ui/docs/ARCHITECTURE.md
T
Jeroen SchweitzerandClaude Opus 4.5 c494ace5d8
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m5s
feat: add URL deep-linking for DataGrids
- Add PageUrlState utility for URL ↔ state serialization
- Add column `id` field for unique column identification in URLs
- Update idSelector to return String for URL compatibility
- All DataGrid pages now support URL params: search, sort, order, id
- Browser URL updates via replaceState (no GoRouter rebuilds)
- Add FilterPanelSemantics for filter panel semantic IDs
- Add TESTING.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:59:08 +01:00

18 KiB

Architecture Guide

This document defines the Clean Architecture patterns and conventions used in Tatlock UI.

Overview

Tatlock UI follows Clean Architecture with feature-based organization. This separation ensures testability, maintainability, and clear boundaries between concerns.

Project Structure

The directory structure mirrors the "Rooms of the Estate" UI navigation (see UI_LAYOUT.md).

lib/
├── main.dart                     # Entry point
├── version.g.dart                # Generated version info
├── app.dart                      # MaterialApp.router setup
├── core/                         # Shared infrastructure
│   ├── api/                      # HTTP client, interceptors, SSE
│   ├── auth/                     # Authentik OIDC integration
│   ├── config/                   # Environment configuration
│   ├── error/                    # Error types and handling
│   ├── semantics/                # Semantic IDs for automation
│   └── theme/                    # Material 3 theming
├── routing/                      # go_router configuration
├── shared/                       # Reusable components
│   ├── components/               # DataGrid, common widgets
│   └── layouts/                  # App scaffold, navigation
├── features/                     # Feature modules (rooms)
│   ├── front_hall/               # Dashboard - estate overview
│   ├── control_room/             # Infrastructure
│   │   ├── containers/           # Container management
│   │   ├── stacks/               # Stack management
│   │   ├── networks/             # Network management
│   │   └── volumes/              # Volume management
│   ├── parlor/                   # Housekeeping - home automation
│   ├── library/                  # Knowledge management (future)
│   └── study/                    # Secretarial tasks (future)
└── chat/                         # Tatlock chat - omnipresent, NOT a room

Room-to-Directory Mapping

UI Room Directory Purpose
Front Hall features/front_hall/ Dashboard, overview, quick access
Control Room features/control_room/ Infrastructure management
Parlor features/parlor/ Home automation
Library features/library/ Knowledge, docs, bookmarks
Study features/study/ Email, calendar (hidden for now)
(omnipresent) chat/ Tatlock assistant dock

Note: chat/ lives at the top level of lib/ (not under features/) because it's not a navigable room - it's an omnipresent dock injected at the layout level.

Feature Structure

Each feature follows a three-layer architecture:

features/{feature}/
├── presentation/                 # UI Layer
│   ├── pages/                    # Full-screen route widgets
│   ├── widgets/                  # Feature-specific widgets
│   └── providers/                # Riverpod providers/notifiers
├── domain/                       # Business Logic Layer
│   ├── entities/                 # Immutable business objects
│   ├── repositories/             # Abstract repository interfaces
│   └── usecases/                 # Single-purpose business operations
└── data/                         # Data Layer
    ├── models/                   # JSON-serializable DTOs
    ├── datasources/              # API clients, local storage
    └── repositories/             # Repository implementations

Layer Rules

Domain Layer (Pure Dart)

The domain layer is the core of each feature. It has no dependencies on Flutter, external packages, or other layers.

Entities - Immutable business objects:

// domain/entities/container.dart
class Container {
  const Container({
    required this.id,
    required this.name,
    required this.status,
    required this.image,
  });

  final String id;
  final String name;
  final ContainerStatus status;
  final String image;
}

enum ContainerStatus { running, stopped, paused, restarting }

Repository Interfaces - Abstract contracts:

// domain/repositories/container_repository.dart
abstract class ContainerRepository {
  Future<List<Container>> getContainers();
  Future<Container> getContainer(String id);
  Future<void> startContainer(String id);
  Future<void> stopContainer(String id);
}

Use Cases - Single-purpose operations (optional, for complex logic):

// domain/usecases/restart_unhealthy_containers.dart
class RestartUnhealthyContainers {
  RestartUnhealthyContainers(this._repository);

  final ContainerRepository _repository;

  Future<int> call() async {
    final containers = await _repository.getContainers();
    final unhealthy = containers.where((c) => c.status == ContainerStatus.stopped);

    for (final container in unhealthy) {
      await _repository.startContainer(container.id);
    }

    return unhealthy.length;
  }
}

Data Layer (External Dependencies)

The data layer implements domain interfaces and handles external communication.

Models - JSON-serializable DTOs:

// data/models/container_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'container_model.freezed.dart';
part 'container_model.g.dart';

@freezed
sealed class ContainerModel with _$ContainerModel {
  const factory ContainerModel({
    required String id,
    required String name,
    required String status,
    required String image,
  }) = _ContainerModel;

  factory ContainerModel.fromJson(Map<String, dynamic> json) =>
      _$ContainerModelFromJson(json);
}

extension ContainerModelX on ContainerModel {
  Container toEntity() => Container(
        id: id,
        name: name,
        status: ContainerStatus.values.byName(status),
        image: image,
      );
}

Data Sources - API clients:

// data/datasources/containers_datasource.dart
class ContainersDatasource {
  ContainersDatasource(this._client);

  final ApiClient _client;

  Future<List<ContainerModel>> getContainers() async {
    final response = await _client.get('/infrastructure/containers');
    return (response.data as List)
        .map((json) => ContainerModel.fromJson(json))
        .toList();
  }
}

Repository Implementations:

// data/repositories/container_repository_impl.dart
class ContainerRepositoryImpl implements ContainerRepository {
  ContainerRepositoryImpl(this._datasource);

  final ContainersDatasource _datasource;

  @override
  Future<List<Container>> getContainers() async {
    final models = await _datasource.getContainers();
    return models.map((m) => m.toEntity()).toList();
  }
}

Data Model Patterns

Models handle conversion between API JSON and domain entities. The pattern depends on whether the feature is read-only or CRUD.

Read-Only Models

For data fetched from external systems (Docker, NPM, Portainer) where Flutter doesn't create/update records:

@freezed
sealed class ContainerModel with _$ContainerModel {
  const factory ContainerModel({
    required String id,
    required String name,
    required String status,
  }) = _ContainerModel;

  const ContainerModel._();

  factory ContainerModel.fromJson(Map<String, dynamic> json) =>
      _$ContainerModelFromJson(json);

  /// Converts API response to domain entity.
  Container toEntity() => Container(
    id: id,
    name: name,
    status: ContainerStatus.values.byName(status),
  );
}

Only toEntity() is needed - no fromEntity() or toJson() required.

CRUD Models (Bidirectional)

For data that Flutter creates, updates, and deletes:

@freezed
sealed class QuickLinkModel with _$QuickLinkModel {
  const factory QuickLinkModel({
    @Default(0) int id,
    required String title,
    required String url,
    String? icon,
    String? category,
    @Default(0) int position,
    @JsonKey(name: 'is_visible') @Default(true) bool isVisible,
  }) = _QuickLinkModel;

  const QuickLinkModel._();

  factory QuickLinkModel.fromJson(Map<String, dynamic> json) =>
      _$QuickLinkModelFromJson(json);

  /// Converts API response to domain entity.
  QuickLink toEntity() => QuickLink(
    id: id.toString(),
    name: title,
    url: url,
    iconName: icon ?? 'link',
    category: category,
    sortOrder: position,
    isActive: isVisible,
  );

  /// Creates model from domain entity for API requests.
  factory QuickLinkModel.fromEntity(QuickLink entity) => QuickLinkModel(
    id: int.tryParse(entity.id) ?? 0,
    title: entity.name,
    url: entity.url,
    icon: entity.iconName,
    category: entity.category,
    position: entity.sortOrder,
    isVisible: entity.isActive,
  );
}

Critical rules for CRUD models:

  1. Always use generated toJson() - Never write custom JSON methods that selectively include fields. The generated toJson() from freezed/json_serializable always includes ALL fields, which is the correct behavior.

  2. Never create custom toCreateJson() or similar - This leads to bugs where fields are silently dropped.

  3. Use fromEntity() factory - Maps domain entity fields to API field names.

Form Update Pattern

When updating existing entities in forms, always use copyWith() to preserve existing data:

// ✅ Correct - preserves all existing fields
final updatedLink = existingLink.copyWith(
  name: _nameController.text.trim(),
  url: _urlController.text.trim(),
  category: category.isEmpty ? null : category,
);
await actions.update(updatedLink);

// ❌ Wrong - loses existing data not in form
final newLink = QuickLink(
  id: existingLink.id,
  name: _nameController.text.trim(),
  url: _urlController.text.trim(),
  // Missing: sortOrder, other fields...
);

Datasource Usage

// Create - convert entity to model, use toJson()
Future<QuickLink> createQuickLink(QuickLink link) async {
  final model = QuickLinkModel.fromEntity(link);
  final response = await _dio.post<Map<String, dynamic>>(
    _basePath,
    data: model.toJson(), // Always use generated toJson()
  );
  return QuickLinkModel.fromJson(response.data!).toEntity();
}

// Update - same pattern
Future<QuickLink> updateQuickLink(QuickLink link) async {
  final model = QuickLinkModel.fromEntity(link);
  final response = await _dio.put<Map<String, dynamic>>(
    '$_basePath/${link.id}',
    data: model.toJson(), // Always use generated toJson()
  );
  return QuickLinkModel.fromJson(response.data!).toEntity();
}

Presentation Layer (Flutter + Riverpod)

The presentation layer contains UI code and state management.

Providers - Riverpod state management:

// presentation/providers/containers_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'containers_provider.g.dart';

@riverpod
class ContainersNotifier extends _$ContainersNotifier {
  @override
  Future<List<Container>> build() async {
    final repository = ref.watch(containerRepositoryProvider);
    return repository.getContainers();
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => ref.read(containerRepositoryProvider).getContainers());
  }
}

Pages - Full-screen routes:

// presentation/pages/containers_page.dart
class ContainersPage extends ConsumerWidget {
  const ContainersPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final containersAsync = ref.watch(containersNotifierProvider);

    return containersAsync.when(
      data: (containers) => ContainersList(containers: containers),
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (error, stack) => ErrorDisplay(error: error),
    );
  }
}

Widgets - Feature-specific UI components:

// presentation/widgets/container_card.dart
class ContainerCard extends StatelessWidget {
  const ContainerCard({super.key, required this.container});

  final Container container;

  @override
  Widget build(BuildContext context) {
    // Widget implementation
  }
}

Dependency Flow

Presentation → Domain ← Data
     ↓           ↑        ↓
  Riverpod   Interfaces  Dio/API
  • Presentation depends on Domain (uses entities, calls repository interfaces)
  • Data depends on Domain (implements repository interfaces)
  • Domain depends on nothing (pure Dart)

Dependency Injection

Use Riverpod for all dependency injection:

// Datasource provider
@riverpod
ContainersDatasource containersDatasource(Ref ref) {
  return ContainersDatasource(ref.watch(apiClientProvider));
}

// Repository provider (returns interface type)
@riverpod
ContainerRepository containerRepository(Ref ref) {
  return ContainerRepositoryImpl(ref.watch(containersDatasourceProvider));
}

Persistent Providers

By default, @riverpod generates providers with isAutoDispose: true, meaning they dispose when no longer watched. This causes issues for:

  • API clients with interceptors that store a Ref
  • App-level state like theme, auth, config
  • Providers with listeners to other providers

Use @persistentRiverpod from core/providers/annotations.dart for these cases:

import 'package:tatlock_ui/core/providers/annotations.dart';

// ✅ Correct - persists for app lifetime
@persistentRiverpod
Dio coreApiClient(Ref ref) { ... }

@persistentRiverpod
class ThemeNotifier extends _$ThemeNotifier { ... }

// ❌ Wrong - auto-dispose can invalidate stored Ref
@riverpod
Dio coreApiClient(Ref ref) { ... }

When to use @persistentRiverpod:

Use Case Annotation Example
API clients with interceptors @persistentRiverpod coreApiClient, tatlockApiClient
Auth state provider @persistentRiverpod AuthNotifier
Theme/config providers @persistentRiverpod ThemeNotifier
Feature data providers @riverpod (default) ContainersNotifier
UI state providers @riverpod (default) SearchFilterNotifier

File Naming Conventions

Type Convention Example
Pages {name}_page.dart containers_page.dart
Widgets {name}_{type}.dart container_card.dart
Providers {name}_provider.dart containers_provider.dart
Entities {name}.dart container.dart
Models {name}_model.dart container_model.dart
Repositories {name}_repository.dart container_repository.dart
Datasources {name}_datasource.dart containers_datasource.dart
Use Cases {action}_{noun}.dart restart_container.dart

Testing Requirements

Each layer has specific testing requirements:

Domain Layer

  • Unit tests for all entities and use cases
  • No mocking needed (pure Dart)
  • 100% coverage expected

Data Layer

  • Unit tests for model serialization
  • Integration tests for datasources (mock HTTP)
  • Test entity conversions

Presentation Layer

  • Widget tests for all pages and complex widgets
  • Provider tests for state management logic
  • Mock repositories using mocktail

Code Generation

Run after modifying annotated code:

dart run build_runner build --delete-conflicting-outputs

Generated files:

  • *.freezed.dart - Immutable classes
  • *.g.dart - JSON serialization, Riverpod providers

Freezed 3.x: Required sealed class

Freezed 3.x requires the sealed keyword on all classes with generated mixins. Without it, the generated code will fail to compile with errors about missing concrete implementations.

// ✅ Correct - Freezed 3.x
@freezed
sealed class UserModel with _$UserModel {
  const factory UserModel({
    required String id,
    required String name,
  }) = _UserModel;

  factory UserModel.fromJson(Map<String, dynamic> json) =>
      _$UserModelFromJson(json);
}

// ❌ Wrong - will fail to compile
@freezed
class UserModel with _$UserModel {  // Missing `sealed`
  const factory UserModel({...}) = _UserModel;
}

The sealed keyword was introduced in Dart 3.0 and allows the generated mixin _$UserModel to have abstract members that are implemented by the private _UserModel class.

Always use sealed class with @freezed - this applies to all models, entities, and state classes using Freezed.

Import Rules

  1. Never import from data/ in domain/
  2. Never import from presentation/ in domain/ or data/
  3. Import entities from domain/, not models from data/
  4. Use relative imports within a feature, package imports across features
// Good - within feature
import '../domain/entities/container.dart';

// Good - across features
import 'package:tatlock_ui/features/containers/domain/entities/container.dart';

// Bad - importing model in presentation
import '../data/models/container_model.dart'; // Don't do this

Semantic Identifiers for Automation

All interactive widgets should have semantic identifiers for UI automation. This enables reliable testing with Puppeteer, Appium, and other automation tools.

Quick Reference

import 'package:tatlock_ui/core/semantics/semantic_ids.dart';

// Wrap interactive widgets with Semantics
Semantics(
  identifier: DataGridSemantics.row(item.id),
  label: 'Select ${item.name}',
  child: MyRowWidget(item: item),
)

Key Points

  1. Central ID Registry - All IDs defined in lib/core/semantics/semantic_ids.dart
  2. Naming Convention - {area}_{component}_{identifier} (e.g., dataGrid_row_abc123)
  3. Web Enabled - Semantics tree exposed via SemanticsBinding.instance.ensureSemantics() in main.dart

For complete documentation on semantic patterns, automation queries, and best practices, see TESTING.md.