agentleFS
alfredobs97alfredobs97/flutter_agentic_template/.agents/skills/domain-modeling/SKILL.md

domain-modeling

Skill0 starsChanged 50 days agoMITView on GitHub

Deep dive on designing a domain entity in packages/domain — Equatable value equality, the hand-written copyWith sentinel pattern for nullable fields, variant inheritance for entities tied to another entity, the sortedX getter convention, and where business-rule methods belong; use whenever designing or reviewing an entity or value object.

195 other projects use a version of this.

Adopted by

Most recently taken up by

Used with

Projects with it also use setup-ts-deep-modules, decision-mapping and 10 more.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • liufeng1976Skill

    setup-ts-deep-modules

    Wire dependency-cruiser into a TypeScript repo so each package is a deep module — implementation hidden in subfolders, reachable only through its entry-point files. User-invoked.

    Also in 43 projects that use this

  • FabienDehopreSkill

    decision-mapping

    Turn a loose idea into a sequenced map of investigation tickets, then drive them to resolution one at a time.

    Also in 8 projects that use this

  • LesaniSkill

    writing-beats

    Shape an article as a journey of beats, choose-your-own-adventure style. The user picks a starting beat from the raw material, you write only that beat, then offer options for where to pivot next, beat by beat, until the article reaches a natural end. Use when the user has raw material and wants to assemble it as a narrative rather than an argument.

    Also in 62 projects that use this

  • RoentekSkill

    loop-me

    Grill me about specs for the workflows I want to build, within this workspace.

    Also in 56 projects that use this

  • AWSSkill

    wizard

    Generate an interactive bash wizard that walks a human through steps only they can perform. Use when provisioning infrastructure, setting up credentials or CI secrets, walking an unfamiliar third-party dashboard, or running a one-off migration or cutover. Don't invoke this for steps the agent can perform itself.

    Also in 134 projects that use this

  • LesaniSkill

    writing-shape

    Take a markdown file of raw material and shape it into an article through a conversational session — drafting candidate openings, growing the piece paragraph by paragraph, arguing about format (lists, tables, callouts, quotes) at each step. Use when the user has a pile of notes, fragments, or a rough draft and wants help turning it into something publishable.

    Also in 60 projects that use this

  • +1 more from Lesani

The file

We split this skill into the rules it gives an agent

9 rules. 0 are common, 2 are distinctive, and 7 are very specific to this project.

  • 0

    Common

    Most of the 183 edited versions we read keep these.

  • 2

    Distinctive

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every example below models a generic Task/Project domain.

    line 14
  2. Very specific:

    Every constructor field is final.

    line 46
  3. Very specific:

    Do not apply this pattern to a non-nullable field (title, isDone above) — a plain T?

    line 105
  4. Distinctive:

    When an entity is *sometimes* tied to another entity, resist adding a nullable foreign-key field to the base class:

    line 115
  5. Very specific:

    Never assume a List field is already in a meaningful order — a data provider may return rows in insertion order, primary-key order, or …

    line 180
  6. Very specific:

    If the rule needs only this's fields (plus maybe a passed-in "current time"/"current user" for testability, as now is above), it's an …

    line 253
  7. Distinctive:

    If the rule needs to *combine data from two different entities* that aren't in a parent/child relationship (e.g.

    line 256
  8. Very specific:

    If the rule needs BuildContext, a DateTime.now() with no way to inject a fixed clock for a test, or any Flutter/UI concept, it does not …

    line 261
  9. Very specific:

    Never write the same conditional (dueDate != null && !isDone && ...) inline in more than one Cubit or widget — that duplication is the …

    line 265

“Common” is measured: kept by at least 6 in 10 of the 183 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Domain modeling: designing an entity

An entity lives in packages/domain/lib/src/entities/. It is pure Dart — no flutter, no data, no storage/network package (packages/domain/AGENTS.md and AGENTS.md section 2) — and it is the one place business rules that belong to the thing itself (not to a particular screen or use case) should live. This skill covers entity design in isolation; for the full entity-to-screen sequence see .agents/skills/new-feature/SKILL.md.

Every example below models a generic Task/Project domain. Do not copy naming from an unrelated app's domain model — mirror the shape demonstrated here and in packages/domain/lib/src/entities/feature_flag.dart, not any specific example's field names.

1. Base shape: Equatable, immutable, no generated code

import 'package:equatable/equatable.dart';

final class Task extends Equatable {
  const Task({
    required this.id,
    required this.title,
    required this.isDone,
    this.dueDate,
  });

  final String id;
  final String title;
  final bool isDone;
  final DateTime? dueDate;

  @override
  List<Object?> get props => [id, title, isDone, dueDate];
}
  • final class, not class — closes the entity to further subclassing from outside packages/domain unless you're deliberately modeling a variant (section 3 below), in which case the base becomes non-final on purpose.
  • Every constructor field is final; there are no setters. A "change" is always a new instance via copyWith.
  • props lists every field that participates in equality — forgetting one here is the most common way two logically-different entities compare as equal in a test or a BlocBuilder rebuild check.
  • No freezed, no json_serializable. copyWith is hand-written (see entities.dart's doc comment) so the sentinel pattern below is expressible without a code-generation dependency domain isn't allowed to have.

2. copyWith and the _unset sentinel for explicitly-nullable fields

A naive copyWith cannot distinguish "leave this field alone" from "explicitly set it to null" when the parameter itself is nullable — both cases look like passing null. The fix is a private sentinel value that is never a legitimate argument:

const _unset = Object();

final class Task extends Equatable {
  const Task({
    required this.id,
    required this.title,
    required this.isDone,
    this.dueDate,
  });

  final String id;
  final String title;
  final bool isDone;
  final DateTime? dueDate;

  Task copyWith({String? title, bool? isDone, Object? dueDate = _unset}) => Task(
    id: id,
    title: title ?? this.title,
    isDone: isDone ?? this.isDone,
    dueDate: identical(dueDate, _unset) ? this.dueDate : dueDate as DateTime?,
  );

  @override
  List<Object?> get props => [id, title, isDone, dueDate];
}

Why each part matters:

  • dueDate's parameter type is Object?, not DateTime? — this is what lets it accept the sentinel (_unset, itself an Object) as its default value. A DateTime? parameter could not default to an Object.
  • The check is identical(dueDate, _unset), not dueDate == _unset — identical is the correct equality for a marker object; == would work here too since Object doesn't override it, but identical documents the intent unambiguously.
  • Three call sites, three distinct outcomes:
    task.copyWith();                       // dueDate unchanged
    task.copyWith(dueDate: DateTime(2026)); // dueDate set to 2026-01-01
    task.copyWith(dueDate: null);           // dueDate explicitly cleared
  • Do not apply this pattern to a non-nullable field (title, isDone above) — a plain T? parameter with ?? this.field is sufficient there, and adding the sentinel unconditionally to every field is needless ceremony that obscures which fields actually need it.
  • id is deliberately not a copyWith parameter at all — identity doesn't change across a copyWith call; if code needs a different id, it should construct a new Task(...), not copyWith one.

3. Variant inheritance: a non-nullable reference on a subclass, not a nullable field on the base

When an entity is sometimes tied to another entity, resist adding a nullable foreign-key field to the base class:

// AVOID — every caller has to null-check projectId even when the domain
// rule is "an assigned task always has a project".
final class Task extends Equatable {
  const Task({required this.id, required this.title, this.projectId});
  final String? projectId;
}

Model it as a subclass with the reference made non-nullable instead:

class Task extends Equatable {
  const Task({required this.id, required this.title, required this.isDone});

  final String id;
  final String title;
  final bool isDone;

  @override
  List<Object?> get props => [id, title, isDone];
}

/// A [Task] that has been assigned to a project. [projectId] is guaranteed
/// non-null — there is no such thing as an [AssignedTask] without one.
final class AssignedTask extends Task {
  const AssignedTask({
    required super.id,
    required super.title,
    required super.isDone,
    required this.projectId,
  });

  final String projectId;

  @override
  List<Object?> get props => [...super.props, projectId];
}

The base class (Task here) must be non-final (or sealed/abstract) for this to compile — only make it non-final when you actually intend a subclass; a plain value entity with no variant stays final class as in section 1.

Callers narrow with a type check instead of a null check:

// Before (nullable field): if (task.projectId != null) { ... task.projectId! ... }
// After (variant):
if (task is AssignedTask) {
  scheduleReminder(task.projectId); // no `!`, no nullability to forget
}

This scales to more than one variant dimension — e.g. RecurringTask extends Task with a non-nullable frequency — each modeling one "this kind of task always has this extra, mandatory piece of data" rule as a type, not a null-checked optional field shared by every kind.

4. The sortedX getter convention

Never assume a List field is already in a meaningful order — a data provider may return rows in insertion order, primary-key order, or whatever order its underlying query happened to produce. If a field's order matters to a caller, expose a getter that sorts on demand instead of promising (or silently relying on) the backing field being pre-sorted:

final class Project extends Equatable {
  const Project({required this.id, required this.name, required this.tasks});

  final String id;
  final String name;
  final List<Task> tasks;

  /// Tasks in due-date order, undated tasks last. Use this instead of
  /// [tasks] directly whenever display or processing order matters — the
  /// backing field's order is not guaranteed.
  List<Task> get sortedTasks {
    final sorted = [...tasks];
    sorted.sort((a, b) {
      if (a.dueDate == null && b.dueDate == null) return 0;
      if (a.dueDate == null) return 1;
      if (b.dueDate == null) return -1;
      return a.dueDate!.compareTo(b.dueDate!);
    });
    return sorted;
  }

  @override
  List<Object?> get props => [id, name, tasks];
}

props still lists the unsorted tasks — a getter is derived, not stored state, so it must not appear in props (it would just recompute the same comparison Equatable already does structurally on the list).

5. Where business-rule methods belong

A rule that only needs the entity's own fields to evaluate belongs on the entity as a method or getter — not duplicated inside every Cubit that needs the answer, and not left for the UI layer to compute from raw fields:

final class Task extends Equatable {
  const Task({
    required this.id,
    required this.title,
    required this.isDone,
    this.dueDate,
  });

  final String id;
  final String title;
  final bool isDone;
  final DateTime? dueDate;

  /// True once [dueDate] has passed and the task is still open. A screen
  /// renders this as an overdue badge; a Cubit filters on it to build an
  /// "overdue" list — both read this getter instead of re-deriving the
  /// `dueDate.isBefore(now) && !isDone` check themselves.
  bool isOverdue({DateTime? now}) {
    final reference = now ?? DateTime.now();
    return dueDate != null && !isDone && dueDate!.isBefore(reference);
  }

  @override
  List<Object?> get props => [id, title, isDone, dueDate];
}

Guidelines for the boundary:

  • If the rule needs only this's fields (plus maybe a passed-in "current time"/"current user" for testability, as now is above), it's an entity method.
  • If the rule needs to combine data from two different entities that aren't in a parent/child relationship (e.g. "is this task visible to this user"), it belongs on the repository instead (packages/domain/lib/src/repositories/) — repositories hold the orchestration a use case would hold elsewhere (packages/domain/AGENTS.md).
  • If the rule needs BuildContext, a DateTime.now() with no way to inject a fixed clock for a test, or any Flutter/UI concept, it does not belong on the entity — keep the entity method pure and deterministic and let the Cubit or widget supply the "now"/"context" it evaluates against.
  • Never write the same conditional (dueDate != null && !isDone && ...) inline in more than one Cubit or widget — that duplication is the signal the rule was supposed to live on the entity.

Checklist

  • Entity extends Equatable; props lists every field, including ones added later.
  • copyWith is hand-written; the sentinel pattern (_unset) is used only for fields that must be explicitly clearable to null — not applied to non-nullable fields.
  • A field tied to another entity that is sometimes absent is modeled as a subclass with a non-nullable reference, not a nullable field on the base.
  • A List field whose order matters has a sortedX getter; the backing field itself is never assumed sorted.
  • A business rule expressible from the entity's own fields is a method on the entity, not duplicated across Cubits.
  • No flutter, dart:io, or storage/network import — tool/check_architecture.dart rejects it (AGENTS.md section 2).
  • Exported from entities.dart, and entities.dart is exported from packages/domain/lib/domain.dart.
  • A test exists under packages/domain/test/entities/ covering copyWith's clear-vs-leave-alone behavior for every nullable field and any entity method added (see AGENTS.md section 6 and .agents/skills/testing/SKILL.md).

Common mistakes

  • Forgetting a field in props — two entities that differ only in that field compare as equal, which silently breaks BlocBuilder's rebuild check and any test asserting inequality.
  • Giving every nullable field the _unset sentinel treatment "for consistency" even when nothing ever needs to clear it back to null — adds a parameter type that isn't T? for no behavioral benefit.
  • Adding a nullable foreign-key field to a base entity instead of a variant subclass, then scattering if (x.fooId != null) null-checks across Cubits and widgets instead of a single is AssignedTask check.
  • Sorting a list field in its constructor and calling that "the sortedX convention" — the getter must exist and do the sort, precisely because the backing field's order is not a guarantee any caller should rely on.
  • Putting a rule that needs BuildContext, context.l10n, or an un-injectable DateTime.now() directly on the entity, making it impossible to unit test with package:test alone (packages/domain/AGENTS.md → "Testing": pure package:test, no Flutter dependency).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.