diff --git a/.agents/skills/ddd-engineer-skills.version b/.agents/skills/ddd-engineer-skills.version new file mode 100644 index 0000000..e403eca --- /dev/null +++ b/.agents/skills/ddd-engineer-skills.version @@ -0,0 +1 @@ +1194355 docs(skills): 📝 Add @haskou/flow skill diff --git a/.agents/skills/ddd-engineer/SKILL.md b/.agents/skills/ddd-engineer/SKILL.md new file mode 100644 index 0000000..2a4b6b6 --- /dev/null +++ b/.agents/skills/ddd-engineer/SKILL.md @@ -0,0 +1,427 @@ +--- +name: ddd-engineer +description: Implement, refactor, review, and document software using practical Domain-Driven Design, SOLID, explicit application boundaries, value objects, tests, and public-contract discipline. +--- + +# DDD engineer + +Use this skill when the user asks to implement, refactor, review, or document code where domain boundaries, application use cases, value objects, repositories, events, API contracts, or acceptance tests matter. + +This is an execution skill, not a theoretical DDD checklist. Read the existing code first, follow local patterns, and leave the codebase cleaner than you found it. + +## Required references + +- Read repository instructions first when present: `AGENTS.md`, `CONTRIBUTING.md`, architecture docs, or context docs. +- Inspect existing code in the same area before adding structure. +- For a backend slice, inspect at least one nearby aggregate/entity, use case, message/command/query, repository, route/controller, mapper/resource, and test. +- For a frontend slice, inspect the existing feature boundary, application service/hook, domain model, API gateway, component structure, and tests. +- When a project uses a value-object library, follow its comparison, validation, and serialization rules instead of treating value objects as decorated primitives. +- Open the focused reference files in `references/` only when the task touches that topic: + - `references/aggregates.md` for aggregate roots, child entities, invariants, lifecycle transitions, and aggregate-emitted events. + - `references/bounded-contexts.md` for cross-context changes, context maps, shared kernels, and anticorruption layers. + - `references/contract-changes.md` for APIs, events, websocket, pub/sub, push, jobs, persistence schemas, and downstream consumers. + - `references/cqrs-read-models.md` for queries, search, dashboards, projections, read models, and read-only screens. + - `references/domain-events.md` for domain events, integration events, eventual consistency, outbox/event publication, and event consumers. + - `references/domain-modeling-decisions.md` for deciding whether a concept is a value object, entity, aggregate, policy, specification, factory, read model, or process manager. + - `references/naming-rules.md` for naming classes, files, folders, messages, commands, queries, ports, events, and tests. + - `references/pr-checklist.md` for PR handoff, review responses, and final verification summaries. + - `references/repositories-transactions.md` for repository boundaries, transactions, units of work, concurrency, outbox, and query persistence. + - `references/value-objects.md` for value-object behavior, primitive conversion, serialization, equality, sorting, filtering, and authorization decisions. + +## Operating mode + +- Work in the language the user uses. +- Prefer a small complete slice over broad half-finished architecture. +- Apply YAGNI as a hard rule. Do not add abstractions, methods, parameters, helpers, folders, or extension points until a real current use case requires them. +- Do not revert user changes. If the worktree is dirty, inspect it and keep unrelated edits intact. +- Make incremental commits when a coherent slice is finished, following the repository's commit convention. +- Treat PR comments as actionable engineering feedback unless they are clearly informational. +- If a public contract changes, explain the change for consumers of that contract. + +## Project conventions + +- Every method should have an explicit return type unless the local framework pattern makes that impossible. +- Every class member should declare visibility explicitly: `public`, `private`, or `protected`. +- Dependency injection should follow the project's application/infrastructure pattern. Do not couple use cases or domain classes to a concrete DI container, service locator, or framework decorator. +- Application APIs commonly use: + - `bodies/` for HTTP body DTO validation. + - `requests/` for presentation-to-application request objects. + - `routes/` for thin HTTP controllers. + - `resources/` or `view-models/` for API response shape. + - `voters/` or policies for authorization decisions when the project already uses them. +- Asynchronous apps commonly use: + - `events/` for integration/domain event mapping when applicable. + - `consumers/` for message-bus consumers. +- Source layout should remain close to: + - `src/apps//...` + - `src/contexts//{application,domain,infrastructure}/...` + - `src/shared/{application,domain,infrastructure}/...` + +## Domain rules + +- Domain objects receive value objects and domain objects, not primitive props bags. +- Domain behavior belongs in aggregates, entities, value objects, domain services, or policies. +- Do not move domain rules into application services, controllers, mappers, repositories, schedulers, or private helper methods. +- Constructors should express the domain model. If a constructor grows unwieldy, introduce a cohesive domain concept or factory only when it removes real complexity. +- A domain constructor with many parameters is a smell, but a generic `props` bag is not the automatic fix. Prefer naming the missing concept: metadata, scope, payload, permissions, participants, window, period, etc. +- Aggregates should expose intent-revealing behavior. Prefer `message.markAsDeleted(byIdentity)` or `community.canManageChannels(identity)` over external code mutating fields or comparing raw ids. +- Aggregate roots must not expose public assertion methods. Public `assertCan...`, `assertIs...`, or `assertHas...` methods on an aggregate root are a design smell; assertions are internal guards, not part of the aggregate's public language. +- If an aggregate collects too many private assertion helpers, extract a cohesive validation class, policy, or domain service named by the concept. For example, move admin checks into `UserValidator.assertIsAdmin(userId)`, and keep `isAdmin(userId)` there too when a boolean question is needed. +- Do not create an anemic domain model wrapped by procedural services. +- Do not use casts in domain or application code unless there is no sane alternative. +- Do not introduce magic strings. Put named domain values behind value objects, constants, or existing enum-style domain values. +- Avoid cross-context domain calls. Coordinate through application services, repositories, domain events, pub/sub, jobs, or API flows. +- Domain events should describe something that happened in ubiquitous language. They should not be transport DTOs with domain-ish names. + +## Encapsulation, getters, and setters + +- Do not add getters or setters to domain/application objects just to make tests easier or to let callers inspect internals. +- Do not add any production method whose only consumer is a test. Tests must exercise real public behavior, persisted state, emitted events, or boundary serialization; they must not force test-only APIs into the model. +- Public getters are allowed only when they expose a genuine boundary value or a stable domain concept that callers are meant to know. They are not a license to pull primitives out and make decisions elsewhere. +- If a method is a getter, name it with a `get` prefix. Use `getAddress()` instead of `address()` when the method only returns the address. +- If a method is a setter, name it with a `set` prefix. Use `setAddress(address)` instead of `address(address)` when the method only replaces the address. +- Do not hide getters or setters behind noun-only method names to avoid an apparent code smell. The smell is unnecessary state exposure or mutation, not the `get` or `set` word itself. +- Setters are almost always wrong in domain code. Prefer intent-revealing behavior: + - `message.edit(payload, editedBy, editedAt)` instead of `message.setPayload(payload)`. + - `community.renameChannel(channelId, name, actor)` instead of `channel.setName(name)`. + - `identity.updateProfile(profile, timestamp)` instead of `identity.setProfile(profile)`. +- If a test needs to inspect state after behavior, prefer testing observable domain behavior, recorded domain events, repository persistence, or serialized shape at a boundary. +- If application code needs a getter to compare, filter, authorize, or branch, move that question into the aggregate, entity, value object, collection, or policy. +- Domain behavior is different from getters and setters. Keep ubiquitous-language methods for questions and commands that express behavior: + - `belongsTo(identityId)`. + - `wasCreatedBy(identityId)`. + - `canBeEditedBy(identityId)`. + - `hasParticipant(identityId)`. + - `includesPermission(permission)`. +- Do not expose mutable arrays, maps, or nested objects. Return domain collections, immutable copies, or answer the question through behavior. +- A getter returning a primitive from a value object is a Demeter warning. At boundaries it may be acceptable; in domain/application decisions it is usually a design bug. + +## Application rules + +- Every application entrypoint that receives external primitives or DTOs must have an explicit message, command, query, or request object. +- That boundary object receives primitives and converts them to value objects. +- Use cases receive boundary messages, value objects, domain objects, or explicit application results. They should not receive anonymous primitive bags. +- Use cases expose ubiquitous-language methods such as `create`, `find`, `send`, `accept`, `update`, `delete`, `reconcile`, or `publish`. +- Do not default to generic method names like `execute` when the codebase expects ubiquitous language. +- Private methods in use cases are only for orchestration mechanics. If a private method names a business rule, move it into the domain. +- Do not create pass-through ports. A port must describe a real outbound dependency in ubiquitous language. +- Do not use defensive programming as a default. Do not return `Foo | undefined`, `Foo | null`, or broad optional unions just to force every caller to guard against missing data. +- If absence is part of the ubiquitous language, model it explicitly as a domain concept. If the project uses `@haskou/value-objects` and a required value/object is missing, return `NullObject.new(Foo)` instead of widening the return type, and let the appropriate error handler handle that failure path. + +## Messages, commands, and queries + +- Boundary messages are translators, not domain models. +- A message may receive primitives because it lives at the application boundary. +- A message should expose value-object getters or a method that returns a cohesive application input, depending on the local pattern. +- Message classes should convert primitives once. Do not duplicate primitive-to-value-object conversion in routes, use cases, and private methods. +- Keep message names tied to intent: `CreateCommunityMessage`, `SendMessageCommand`, `FindIdentityQuery`, `PublishKeychainMessage`. +- Avoid vague message names such as `RequestData`, `Input`, `Payload`, `Params`, `Body`, or `Props` unless they are API DTOs in the presentation layer. +- Do not pass API body classes directly into use cases if the codebase separates presentation and application. +- Optional fields should still be explicit. A missing primitive should become a meaningful absence in the message, not a surprise `undefined` leaking into the domain. +- If a message needs complex validation that names domain rules, push that behavior into a value object, entity, aggregate, or policy. +- If two use cases receive the same primitives but mean different things, create two message classes. Reuse is less important than clear intent at the boundary. +- If several messages duplicate the same conversion, extract a domain value object or a mapper at the correct boundary; do not create a generic conversion helper by reflex. + +## Value objects + +- Prefer existing value-object base classes or project value-object libraries before creating custom primitive wrappers. +- Prefer derived primitive helper types supplied by the value-object library over hand-written primitive aliases. +- Compare value objects through behavior: + - equality methods for identity/equality. + - numeric comparison methods for numbers. + - timestamp and interval methods for time. + - domain-specific methods for domain-specific questions. +- Serialization methods such as `toPrimitives()`, `valueOf()`, and `toString()` are boundary tools only: persistence, DTOs, events, logs, telemetry, external libraries, and contract tests. +- Do not pull primitive internals out of value objects to compare, sort, filter, or branch in domain/application logic. +- If behavior is missing, add it to the value object instead of writing a helper that knows its internals. + +## Serialization and hydration + +- `toPrimitives()` serializes a domain object for a boundary. It is not a general-purpose getter. +- `fromPrimitives()` hydrates from persistence, fixtures, or external payloads at a boundary. It is not a shortcut constructor for application/domain code. +- Valid places for `toPrimitives()`: + - repository persistence. + - resource or DTO mapping. + - published event payloads. + - logs, telemetry, and external SDK calls. + - contract tests that assert serialized shape. +- Valid places for `fromPrimitives()`: + - repository hydration. + - test fixtures that intentionally build persisted state. + - message classes only when the primitive payload is explicitly a serialized version of a value object or aggregate and the local pattern allows it. +- Invalid uses: + - equality checks. + - authorization rules. + - sorting or filtering domain collections. + - deciding lifecycle transitions. + - reaching through nested values because adding a method felt slower. +- Prefer behavior: + - `identityId.isEqual(otherIdentityId)` over `identityId.toPrimitives() === otherIdentityId.toPrimitives()`. + - `period.includes(timestamp)` over comparing serialized dates. + - `role.can(permission)` over checking a primitive permission array outside the role. +- If a caller needs a primitive to make a decision, first ask whether that decision belongs inside the value object, entity, aggregate, or policy. + +## Infrastructure and boundaries + +- Persistence models, API DTOs, OpenAPI schemas, pub/sub payloads, websocket payloads, and external SDK payloads must stay out of the domain. +- Infrastructure must not contain business logic or domain decisions. Repositories, adapters, mappers, controllers, consumers, and SDK clients move data across boundaries; they do not decide permissions, lifecycle transitions, invariants, eligibility, pricing, status changes, or ownership rules. +- If infrastructure code needs a business decision to persist, map, publish, or call an external service, move that decision into the aggregate, entity, value object, policy, domain service, or application use case and pass infrastructure only the result. +- Repositories hydrate and serialize aggregates. They may call serialization methods; domain code should not need to. +- API routes/controllers should be thin: parse request, build boundary message, call use case, return resource. +- Resource/mapper classes are responsible for presentation shape, not domain decisions. +- Events, pub/sub, websocket, job, and push contracts are integration boundaries. Keep them explicit, documented, and tested where behavior matters. +- Shared infrastructure is only for genuinely generic concerns. Context-specific adapters belong inside their owning context. + +## Structure rules + +- Follow the existing module/context layout before inventing folders. +- Prefer explicit layer ownership: + - `domain/` + - `domain/value-objects/` + - `application//` + - `application//messages/` or the project's equivalent command/query folder. + - `infrastructure//` +- Do not add root-level `types`, `utils`, `dto`, `messages`, `services`, `ports`, `helpers`, or `common` buckets inside a module unless the project already has a clear, layer-owned convention for them. +- Avoid vague class names such as `Manager`, `Helper`, `Utils`, `Common`, `Base`, `Data`, or `Info`. +- Split classes when they have multiple reasons to change. Do not hide a god object behind a prettier name. + +## Domain folder structure + +- `domain/` contains the business model and business rules. It must not know HTTP, database schemas, queues, SDK payloads, UI components, OpenAPI, JSON resource shapes, or framework decorators. +- Domain code may depend on other domain concepts in the same bounded context and on shared domain primitives/value-object base classes. It should not depend on application or infrastructure. +- Keep domain folders named by domain concept, not by technical convenience. + +### `domain/.ts` + +- Use for aggregate roots: objects that own consistency boundaries and lifecycle rules. +- Aggregate roots coordinate entities/value objects inside the aggregate. +- They expose intent-revealing methods such as `publish`, `rename`, `join`, `leave`, `markAsRead`, `delete`, `accept`, or `reject`. +- They should protect invariants internally. External code should not need to inspect fields and decide if a transition is allowed. +- Constructors receive value objects and domain objects, not API DTOs or primitive props bags. +- Aggregate roots should record domain events when they are created and when meaningful domain mutations happen. +- A mutating aggregate method should usually either: + - change no state because the operation is idempotent or rejected. + - change state and record the corresponding domain event. +- Do not let application services, repositories, or controllers invent domain events for aggregate state changes. The aggregate that owns the invariant should record the event. +- Domain events should be recorded after invariants pass and state has changed, not before validation. +- Do not record events for purely technical persistence/hydration changes. + +### `domain/.ts` + +- Use for domain entities that have identity and lifecycle but are not aggregate roots. +- Entities should still own their behavior. Do not make them passive records unless they are genuinely immutable data under an aggregate. +- Entity identity should be modeled with a value object. +- If an entity cannot exist independently from an aggregate, keep its mutations controlled by the aggregate. + +### `domain/value-objects/` + +- Use for immutable concepts defined by their value, validation, formatting, and behavior. +- Value objects are the right place for: + - ids and references. + - names, handles, titles, descriptions, statuses, scopes, permissions, limits, counters, timestamps, windows, and serialized external identifiers. + - validation rules such as length, allowed characters, ranges, protocol constraints, or domain-specific parsing. + - comparison behavior such as equality, ordering, inclusion, expiration, matching, or permission checks. +- A value object should answer domain questions without leaking primitives. +- Do not create a custom value object if the project or a value-object library already has a suitable one. +- Do not create a value object that only wraps a primitive and adds no validation or behavior unless it clarifies a real domain concept. + +### `domain/events/` + +- Use for domain events: immutable facts that already happened in the domain. +- Event names should be past tense and ubiquitous-language based, such as `MessageWasSent`, `IdentityWasUpdated`, or `CommunityMemberWasAdded`. +- Domain events should contain only the data needed by consumers to react or fetch state. +- Domain events are not transport payloads. Infrastructure can map them to pub/sub, websocket, queue, or integration payloads. +- Do not put command/request intent in events. `CreateUserEvent` is usually wrong; `UserWasCreated` is the domain fact. +- Creation events should be emitted by the aggregate creation path, normally via a named factory/static constructor or constructor pattern already used by the project. +- Mutation events should be emitted by aggregate behavior methods, not by external orchestration after calling setters. +- Event payloads should identify the aggregate, relevant actor when applicable, and the minimal changed facts. Do not dump the whole aggregate unless that is the explicit local event contract. +- Rehydrating an aggregate from persistence must not re-record historical domain events. + +### `domain/repositories/` + +- Use for repository interfaces owned by the domain/application boundary. +- Repository methods should speak ubiquitous language: `save`, `search`, `findById`, `findByOwner`, `matching`, `exists`, `delete`, depending on local convention. +- Repository interfaces should accept and return aggregates, entities, value objects, or domain collections, not database rows or API DTOs. +- Repository implementations belong in infrastructure. +- Do not leak Mongo, SQL, Redis, HTTP, IPFS, filesystem, or SDK concepts into domain repository interfaces. + +### `domain/services/` + +- Use only for domain behavior that genuinely spans multiple domain objects and does not naturally belong to one aggregate or value object. +- Domain services should be rare, cohesive, and named by the business capability or decision. +- They should receive domain objects/value objects and return domain objects/value objects/domain decisions. +- Do not use domain services as a dumping ground for code that felt awkward inside a use case. +- If a service only calls a repository or external adapter, it is probably application or infrastructure, not domain. + +### `domain/policies/` + +- Use for named reusable business decisions, permissions, or lifecycle rules. +- Policies should answer clear questions: `canDeleteMessage`, `canManageChannel`, `shouldReplicateContent`, `canJoinCommunity`. +- Policies receive domain objects/value objects and return domain decisions, booleans, or domain result objects. +- If a policy needs primitives from a value object, add behavior to the value object instead. +- Do not hide persistence or external calls inside a domain policy. + +### `domain/specifications/` + +- Use only if the project already uses the Specification pattern or the predicate is a meaningful domain concept. +- Specifications should model reusable predicates, not replace simple methods. +- Avoid creating `Specification` classes just to avoid adding behavior to an entity or value object. + +### `domain/factories/` + +- Use for domain creation when construction has real business complexity, multiple valid construction paths, generated domain ids, default domain state, or invariants spanning nested objects. +- Factories receive primitives only if the local project explicitly treats them as boundary factories. Otherwise they receive value objects/domain objects. +- Do not add factories just because a constructor has several parameters. First look for missing named concepts. +- A factory should create a valid domain object, not partially initialized state. + +### `domain/collections/` + +- Use for collections with domain behavior, not as aliases for arrays. +- A domain collection may enforce uniqueness, ordering, membership rules, limits, or query behavior in ubiquitous language. +- Callers should ask the collection domain questions instead of extracting arrays and filtering primitives. + +### `domain/errors/` + +- Use for domain errors that represent violated business rules. +- Error names should describe the rule: `CommunityOwnerCannotLeaveError`, `MessageAlreadyDeletedError`, `InvalidIdentityHandleError`. +- Do not throw generic `Error` for expected business violations. +- Do not put HTTP status codes, controller response shapes, or transport details inside domain errors. + +### `domain/exceptions/` + +- Use only if the project convention says "exceptions" instead of "errors". +- Do not keep both `errors/` and `exceptions/` for the same concept unless the existing codebase already distinguishes them clearly. + +### `domain/enums/` + +- Avoid plain enums when behavior matters. +- Prefer enum-style value objects for statuses, types, permissions, visibility, modes, and lifecycle states. +- If a plain enum exists by convention, keep domain behavior out of external switch statements where possible. +- Do not scatter string literal states across application or infrastructure. + +### `domain/types/` + +- Avoid this folder by default. +- Domain-specific concepts should usually be value objects, entities, policies, or collections. +- Boundary primitive helper types belong near the boundary that serializes/deserializes them. +- If the project uses a value-object helper such as `PrimitiveOf`, prefer it over custom primitive aliases. + +### `domain/utils/` and `domain/helpers/` + +- Do not create these folders. +- A helper in domain almost always means behavior is missing from a value object, entity, aggregate, collection, service, or policy. +- If a utility is truly generic and not domain-specific, it belongs in shared infrastructure or a shared kernel only if the project has one. + +### `domain/constants/` + +- Avoid this folder for business concepts. +- Stable domain values should usually be value objects, enum-style value objects, policies, or named concepts. +- Use constants only for genuinely universal, stable, non-behavioral values where the local codebase convention allows it. + +### `domain/index.ts` + +- Use only as a deliberate public module barrel if the project allows barrels. +- Do not put behavior, initialization, side effects, or unrelated exports in `index.ts`. +- Do not use barrels to hide circular dependencies. + +### `domain/mappers/` and `domain/dto/` + +- Do not create these folders for persistence/API conversion. +- Mapping to database rows, HTTP resources, pub/sub payloads, websocket payloads, and external SDK DTOs belongs outside the domain. +- A domain mapper is acceptable only if it maps between domain concepts and is named by that domain transformation. + +## Naming rules + +- Names should come from the ubiquitous language, not from technical convenience. +- Prefer names that answer "what domain concept is this?" and "why does it change?" +- Good names are cohesive: + - `ConversationMessage`, not `MessageData`. + - `CommunityMembershipRequest`, not `CommunityRequestInfo`. + - `UnreadMessageCounter`, not `CounterHelper`. + - `IdentityPresence`, not `PresenceDto` in domain/application. +- Avoid suffixes that hide missing design: `Manager`, `Helper`, `Utils`, `Processor`, `Handler`, `Service`, `Data`, `Info`, `Common`, `Base`. +- Use `Service` only when the class is genuinely a domain/application/infrastructure service and the local codebase uses that suffix intentionally. +- Use `Handler` for framework/event handlers, not as a dumping ground for orchestration and domain decisions. +- Use `Mapper`, `Projector`, `Resource`, `Presenter`, or `Serializer` only at boundaries where shape conversion is the responsibility. +- Use `Policy` for a reusable domain decision. +- Use `Specification` only when the codebase already uses specification-style predicates and the object represents a meaningful domain predicate. +- Use `Factory` only when object creation has real domain complexity or multiple valid construction paths. +- A folder name should name a layer or business action, not a generic bucket. +- File names should match exported class names unless the project has a clear convention otherwise. +- If a name contains `And`, `Or`, `With`, or several concepts glued together, check whether the class has multiple responsibilities. + +## File naming rules + +- File names should reveal the domain/application concept without opening the file. +- A file exporting one main class should normally have the same name as that class: + - `CreateOrderMessage.ts` exports `CreateOrderMessage`. + - `OrderCreatedDomainEvent.ts` exports `OrderCreatedDomainEvent`. + - `OrderRepository.ts` exports `OrderRepository`. +- Do not name files by technical buckets when they contain domain/application concepts: + - avoid `types.ts`, `helpers.ts`, `utils.ts`, `constants.ts`, `common.ts`, `models.ts`, `data.ts`, `interfaces.ts`, and `index.ts` as dumping grounds. +- A file named `types.ts` is acceptable only when the local convention uses it for pure boundary contract types, never as a place to hide domain decisions. +- A file named `constants.ts` is acceptable only for genuine stable constants at a boundary. Domain values should usually be value objects, enum-style domain values, or named domain concepts. +- A file named `index.ts` should be a deliberate barrel or public module entrypoint. It should not contain domain behavior, application orchestration, or unrelated exports. +- One file should have one main reason to change. If it contains several exported classes with independent responsibilities, split it. +- Keep boundary files named by boundary role: + - `CreateOrderBody.ts` for HTTP body DTOs. + - `OrderResource.ts` for API response resources. + - `MongoOrderRepository.ts` for a Mongo adapter. + - `OrderWasCreatedPubSubPayload.ts` for an integration payload. +- Keep domain/application files named by ubiquitous language: + - `OrderPaymentPolicy.ts`, not `PaymentRules.ts`. + - `CustomerCanPlaceOrder.ts` only if the project uses specification-style predicates. + - `OrderTotal.ts`, not `AmountWrapper.ts`. +- Avoid suffix drift. Do not rename the same kind of concept across the codebase as `Command`, `Message`, `Request`, and `Input` unless those words mean different things in the project. +- Do not put DTO suffixes on domain classes. `OrderDto` is not a domain object. +- Do not put domain suffixes on DTOs. `OrderDomainModelBody` is nonsense. +- Folder names and file names should agree. A file in `application/create-order/messages/` should not be called `Payload.ts`. +- If a file name needs `For`, `With`, `And`, or several nouns to explain itself, check whether it is mixing responsibilities or missing a named concept. +- Test files should mirror the behavior under test: + - `OrderTotal.spec.ts` for value-object behavior. + - `CreateOrder.spec.ts` for a use case. + - `PostOrderRoute.feature` or equivalent for an acceptance route workflow. +- Do not create "misc" files. If the only honest name is generic, the design is not finished. + +## Tests and documentation + +- Add or update focused unit tests for non-trivial domain/application behavior. +- Add or update acceptance or integration tests when routes, workflows, or public contracts change. +- Keep acceptance features separated by route or workflow. Do not create giant catch-all feature files. +- Unit tests should use the repository's existing test framework and mocking conventions. +- Prefer AAA structure: arrange, act, assert. +- Use object mothers/builders when creating complex aggregates, entities, value objects, or persisted fixtures. Keep mothers under the existing test mother folder convention. +- Unit specs should live under the repository's unit test tree and mirror the behavior under test, for example `tests/unit/.../CommunityRole.spec.ts`. +- Acceptance scenarios should live in the existing feature folders: + - API routes under `tests/api/features`. + - Consumers under `tests/consumers/features`. + - Commands under `tests/commands/features` when that folder exists. +- Acceptance scenarios should read like user/system behavior. Use `Given`, `When`, `Then`; avoid scenario names that only repeat implementation method names. +- Cover happy path, negative path, and meaningful domain errors when the behavior has rules. +- Do not assert private state through getters added for tests. Exercise behavior and assert domain events, returned results, persistence, resources, or public contract shape. +- Public endpoint changes must update the project's API docs and machine-readable contract files when they exist. +- Domain or event changes should update context docs and diagrams when the project maintains them. +- Pub/sub, websocket, sync, push, and job contracts should be documented with payload shape, routing, recipients, side effects, and consumer action. + +## PR discipline + +- Before opening or handing off a PR, run the smallest relevant checks first. +- Prefer finishing with the repository's lint, build/typecheck, and relevant tests. +- If checks fail, say exactly what fails and keep fixing unless blocked. +- PR descriptions should include: + - Summary. + - API or contract changes. + - Tests run. + - Notes for frontend or downstream consumers when contracts changed. +- When answering review comments, use the reviewer's language and state what changed or why the code is intentional. + +## Never do this + +- Do not create domain/application shortcuts because a test or route is inconvenient. +- Do not compare serialized primitives in domain/application code. +- Do not put business rules in controllers, mappers, repositories, schedulers, or UI components. +- Do not create redundant primitive type aliases for value objects. +- Do not add empty boundary-message folders or message classes with mismatched filenames. +- Do not invent abstractions before checking the local pattern. +- Do not leave documentation behind after changing public contracts. +- Do not claim a slice is done while lint, build/typecheck, or relevant tests are failing. diff --git a/.agents/skills/ddd-engineer/references/aggregates.md b/.agents/skills/ddd-engineer/references/aggregates.md new file mode 100644 index 0000000..8ed961e --- /dev/null +++ b/.agents/skills/ddd-engineer/references/aggregates.md @@ -0,0 +1,68 @@ +# Aggregates and aggregate roots + +Use this reference when the work touches aggregate design, entity ownership, repository boundaries, lifecycle transitions, invariants, or domain events. + +## Aggregate purpose + +An aggregate is a consistency boundary. It groups the aggregate root, owned entities, and value objects that must change together to preserve invariants. + +Do not introduce an aggregate only to organize files. Introduce or modify an aggregate when there is a real lifecycle, ownership boundary, invariant, or transactional consistency rule. + +## Aggregate roots + +The aggregate root is the only object outside the aggregate that should be loaded, saved, or referenced directly by repositories and application use cases. + +External code should not mutate child entities directly. It should call intent-revealing behavior on the root, such as `conversation.addParticipant(identity)`, `community.archiveChannel(channelId, byIdentity)`, or `order.confirm(payment)`. + +Use cases coordinate aggregate roots; they should not reach through the root to enforce child-entity rules. + +Aggregate roots should not expose public assertion methods. A public `assertCan...`, `assertIs...`, or `assertHas...` method makes callers orchestrate preconditions instead of asking the aggregate to perform behavior or answer a domain question. + +Assertion helpers on an aggregate root should be private implementation details. If there are too many of them, extract a cohesive validation class, policy, or domain service with a clear concept name, such as `UserValidator.assertIsAdmin(userId)`. If callers need the boolean form too, keep `isAdmin(userId)` with that same validator/policy instead of duplicating the rule elsewhere. + +Repositories should normally be organized around aggregate roots, not every entity. Avoid repositories for child entities unless the existing codebase has a deliberate exception. + +## Invariants and transactions + +Keep invariants that must be immediately consistent inside a single aggregate boundary. + +If a rule needs data owned by another aggregate or bounded context, coordinate through application services, domain events, policies, read models, jobs, or process managers instead of making cross-aggregate mutations from inside the domain object. + +Do not inject repositories, external clients, event buses, clocks, or framework services into aggregate roots unless the local architecture explicitly permits it. Prefer passing already-obtained value objects, domain services, policies, or timestamps into behavior. + +## References between aggregates + +Prefer references by identity value object rather than embedding another aggregate. + +A root may receive another aggregate as a method argument when the domain language and local pattern justify it, but it should not take ownership of that aggregate’s lifecycle. + +Avoid primitive ID comparison outside domain behavior. Add methods such as `belongsTo(communityId)`, `isOwnedBy(identityId)`, or `canBeManagedBy(identity)` when the question is domain-specific. + +## Child entities and value objects + +Use child entities when identity within the aggregate matters over time. + +Use value objects when equality is structural and the object has no independent lifecycle. + +Do not promote a child entity to an aggregate root only because a controller or repository wants direct access. First check whether the application flow should load the owning aggregate root and call behavior on it. + +## Domain events from aggregates + +Domain events emitted by aggregates should describe facts that happened in ubiquitous language, such as `CommunityChannelArchived` or `MessageSent`. + +Events should be raised as a result of aggregate behavior, not assembled externally as transport payloads with domain-like names. + +Keep integration payload mapping at the boundary. The domain event may later be mapped to pub/sub, websocket, email, or job payloads. + +## Smells + +- A repository per database table rather than per aggregate root. +- Use cases modifying child entities directly. +- Controllers deciding lifecycle transitions. +- Domain events named after transport actions rather than facts. +- Aggregates that expose setters for every field. +- Aggregate roots exposing public assertion methods. +- Large clusters of private assertions that should be a named validator, policy, or domain service. +- Aggregate roots that need many unrelated services to enforce behavior. +- A large aggregate used to avoid eventual consistency where a process or event would be clearer. +- Empty aggregate folders created before any aggregate code exists. diff --git a/.agents/skills/ddd-engineer/references/bounded-contexts.md b/.agents/skills/ddd-engineer/references/bounded-contexts.md new file mode 100644 index 0000000..0cd2ac3 --- /dev/null +++ b/.agents/skills/ddd-engineer/references/bounded-contexts.md @@ -0,0 +1,50 @@ +# Bounded contexts and context maps + +Use this reference when a change crosses modules, teams, subdomains, external systems, or language boundaries. + +## Bounded context ownership + +Treat each bounded context as owning its own model, language, persistence, and contracts. + +Do not reuse entities, value objects, DTOs, enums, repositories, or domain events across contexts unless the project has an explicit shared-kernel convention. + +If two areas use the same word with different meaning, keep separate model types and name the translation explicitly. + +Inspect existing context/module boundaries before moving code. A name collision across contexts is a signal to clarify language, not to force reuse. + +## Ubiquitous language discovery + +Before naming new concepts, inspect existing domain terms in code, tests, feature files, API docs, event names, architecture docs, and business workflow descriptions. + +Prefer terms already used by the business workflow over generic technical names. + +When the user or codebase uses a domain term, preserve that language unless there is evidence it is inconsistent or outdated. + +## Context relationships + +When integrating with another context, identify the relationship before choosing a design: + +- Shared kernel: a small, stable model intentionally owned by multiple contexts. +- Customer/supplier: downstream needs influence upstream behavior or contract. +- Conformist: downstream deliberately conforms to upstream’s model. +- Anticorruption layer: downstream protects its model from an upstream or legacy model. +- Open-host service: upstream exposes a stable integration surface. +- Published language: contexts exchange a documented public language through APIs, events, schemas, or contracts. + +Do not introduce a formal context-map pattern unless it clarifies the change. Use the terms to choose boundaries and explain integration choices. + +## Anticorruption layers + +Use an anticorruption layer when external systems, legacy modules, or other bounded contexts use concepts that do not match this context’s domain model. + +Translate external DTOs, statuses, enums, identifiers, and lifecycle meanings at the boundary. Do not let foreign models leak into the domain. + +Name translation classes after the integration or published language, not after generic mapping mechanics, unless the codebase already has a mapper convention. + +## Shared kernel + +Use a shared kernel only for small, stable concepts that multiple contexts intentionally own together. + +Shared-kernel changes should be treated as public-contract changes for all consuming contexts. + +Do not create a shared kernel because duplication feels uncomfortable. Duplicated names across contexts may represent different concepts. diff --git a/.agents/skills/ddd-engineer/references/contract-changes.md b/.agents/skills/ddd-engineer/references/contract-changes.md new file mode 100644 index 0000000..cbc7ae8 --- /dev/null +++ b/.agents/skills/ddd-engineer/references/contract-changes.md @@ -0,0 +1,50 @@ +# Public contracts and integration boundaries + +Use this reference when a change affects APIs, events, pub/sub messages, websockets, jobs, sync payloads, push notifications, persistence schemas, or downstream consumers. + +## Boundary types + +Treat these as explicit integration boundaries: + +- API request and response DTOs. +- OpenAPI, GraphQL, protobuf, JSON schema, or other machine-readable contracts. +- Pub/sub payloads. +- Domain-event publication payloads. +- Websocket payloads. +- Job and scheduler payloads. +- Push notification payloads. +- External SDK payloads. +- Persistence models and migrations. + +These shapes must not become domain models. + +## Required updates when contracts change + +When a public contract changes, check for and update: + +- API docs. +- Machine-readable contract files. +- Resource/mapper/presenter/serializer tests. +- Acceptance or integration tests. +- Consumer-facing notes in the final response or PR description. + +When an event, pub/sub, websocket, sync, push, or job contract changes, document: + +- Payload shape. +- Routing topic/channel/event name. +- Recipients or consumers. +- Side effects. +- Expected consumer action. +- Compatibility or migration notes. + +## Contract discipline + +Routes/controllers should be thin: parse request, build a boundary message, call a use case, return a resource. + +Resource, mapper, presenter, serializer, and projector classes are responsible for boundary shape. They should not make domain decisions. + +Repositories hydrate and serialize aggregates. Domain code should not depend on persistence models. + +Domain events should describe something that happened in ubiquitous language. They should not be transport DTOs with domain-ish names. + +If a public contract changes, explain the change for downstream consumers in the final handoff. diff --git a/.agents/skills/ddd-engineer/references/cqrs-read-models.md b/.agents/skills/ddd-engineer/references/cqrs-read-models.md new file mode 100644 index 0000000..6b20d3e --- /dev/null +++ b/.agents/skills/ddd-engineer/references/cqrs-read-models.md @@ -0,0 +1,33 @@ +# CQRS, queries, and read models + +Use this reference when implementing queries, dashboards, search, reports, lists, counts, projections, or read-only screens. + +## Command model versus query model + +Use aggregate behavior for command-side decisions and lifecycle changes. + +Do not hydrate aggregates just to serve read-only screens, lists, counts, dashboards, or search results if the project has read models, projections, query repositories, resources, or API views for that purpose. + +Use read models and projections for query-side representation. They can be shaped for the caller, but they should not make domain decisions. + +## Queries + +A query object may receive primitives at the boundary and convert or validate them according to the local application pattern. + +Query handlers or query use cases should express intent in ubiquitous language, not database mechanics. + +Do not put lifecycle changes, invariant enforcement, or business decisions in query code. + +## Projections + +Projection code translates events or persisted state into query-optimized views. + +Projection payloads and read-model storage are boundary concerns. Keep them out of the write-side domain model. + +When changing a projection, consider backfill, rebuild, migration, idempotency, ordering, and compatibility with existing consumers. + +## Acceptance tests + +For read-side behavior, test the public query result or projection behavior that consumers depend on. + +When a command changes a read model through events or projection updates, add integration or acceptance coverage if the project normally verifies that workflow end to end. diff --git a/.agents/skills/ddd-engineer/references/domain-events.md b/.agents/skills/ddd-engineer/references/domain-events.md new file mode 100644 index 0000000..48aca53 --- /dev/null +++ b/.agents/skills/ddd-engineer/references/domain-events.md @@ -0,0 +1,53 @@ +# Domain events, integration events, and eventual consistency + +Use this reference when raising, recording, publishing, consuming, or mapping events. + +## Event types + +Distinguish domain events from integration events. + +Domain events are facts raised by domain behavior in ubiquitous language. They describe something that happened inside the model. + +Integration events are published boundary contracts for other systems or bounded contexts. + +Outbox records, broker messages, queue payloads, and websocket payloads are persistence or transport mechanics. + +Map domain events to integration events at an application or infrastructure boundary according to local patterns. + +## Domain event design + +Name events in past tense using ubiquitous language, such as `MessageDeleted`, `MembershipAccepted`, or `ChannelArchived`. + +Domain events should carry the information needed to understand the fact that happened. Do not turn them into transport DTOs with domain-ish names. + +Do not put external routing, broker topics, websocket recipients, retry metadata, or transport headers into domain events unless the local architecture intentionally treats those as domain concepts. + +## Event lifecycle + +When an aggregate raises events, follow the project pattern for recording, pulling, clearing, persisting, publishing, or dispatching them. + +Use application or unit-of-work code to coordinate persistence and publication. Do not publish directly from domain objects. + +When aggregate changes can conflict, respect expected-version, revision, optimistic-concurrency, or event-stream patterns. + +## Eventual consistency + +Use eventual consistency for workflows that span aggregate or bounded-context boundaries. + +Use a process manager, saga, application handler, scheduled job, or event consumer when a business process reacts to events and coordinates multiple steps over time. + +Do not enlarge an aggregate only to avoid asynchronous coordination. + +## Contracts and consumers + +When an event is consumed outside the current module/context, treat its payload as a public contract. + +Document payload shape, routing/topic, recipients, side effects, consumer action, ordering/idempotency assumptions, and compatibility notes when the project maintains such docs. + +Add or update integration/contract tests when event payloads, routing, or consumer-visible behavior changes. + +## Event sourcing + +If the project uses event sourcing, rebuild aggregate state from recorded events and persist new events through the existing event-store pattern. + +Do not mix state-based repository shortcuts into an event-sourced aggregate unless the project already has that convention. diff --git a/.agents/skills/ddd-engineer/references/domain-modeling-decisions.md b/.agents/skills/ddd-engineer/references/domain-modeling-decisions.md new file mode 100644 index 0000000..0352ece --- /dev/null +++ b/.agents/skills/ddd-engineer/references/domain-modeling-decisions.md @@ -0,0 +1,55 @@ +# Domain modeling decisions + +Use this reference when deciding where a concept or behavior belongs. + +## Choosing a model type + +Use a value object when equality is structural, identity does not matter, and the concept is defined by its values. Value objects should be immutable when the local language and framework make that practical. + +Use an entity when identity and lifecycle matter inside an aggregate. A child entity should usually be loaded, changed, and persisted through its aggregate root. + +Use an aggregate root when external code needs a consistency, lifecycle, and transaction boundary. Other objects inside the aggregate should not be mutated from outside the root unless the local codebase has an explicit pattern for it. + +Use a domain service only for domain behavior that belongs to the ubiquitous language, requires multiple domain objects, or cannot naturally live on a single aggregate, entity, or value object. A domain service should not orchestrate repositories, DTO mapping, transactions, logging, queues, or framework concerns. + +Use a policy for a named, reusable business decision such as `MessageDeletionPolicy` or `ChannelManagementPolicy`. Do not extract one-line checks unless naming the rule clarifies the model or reuse is real. + +Use a specification only when the codebase already uses specification-style predicates or when the predicate is a meaningful domain concept that benefits from composition. + +Use a factory when creation has real domain meaning, multiple valid construction paths, generated identities, default invariants, or coordinated child-entity creation. Do not create factories just to hide constructors or pass through primitives. + +Use a read model or projection when the use case is query-oriented and should not hydrate aggregates. + +Use a process manager, saga, or job when a business process reacts to events and coordinates multiple steps over time across aggregate or bounded-context boundaries. + +## Invariants versus validation + +Input validation checks shape, required fields, formats, and boundary constraints. It belongs at the boundary: request schemas, message constructors, DTO mappers, or application input validation according to local patterns. + +Domain invariants protect business truth. They belong in aggregate roots, entities, value objects, domain services, policies, or specifications. + +Do not rely on controllers, request schemas, database constraints, or UI validation to enforce domain invariants. Those can provide defense in depth, but they should not be the only place business truth is protected. + +## Lifecycle and state + +Model lifecycle transitions explicitly: create, activate, suspend, archive, delete, restore, expire, publish, cancel, complete. + +Prefer named transition methods over direct status mutation. Invalid transitions should be rejected by domain behavior using the project’s error/result pattern. + +Avoid state strings that are interpreted in many places. Put state transitions and state questions behind domain behavior, value objects, enum-style domain values, or policies. + +## Time and clocks + +Pass time into domain behavior as a value object, timestamp, clock result, or project-specific time object. + +Use clock abstractions at the application boundary when the project provides them. Do not call system time directly inside domain objects unless the local architecture permits it. + +Model periods, windows, expirations, deadlines, and schedules as domain concepts when behavior depends on them. + +## Domain failures + +Use the project’s existing error/result pattern for domain failures. + +Domain errors should name business failures, such as `CannotArchiveLastOwnerChannel` or `MembershipAlreadyAccepted`, not technical failures. + +Do not throw generic errors for domain-rule violations if the project has typed domain errors, result objects, or failure values. diff --git a/.agents/skills/ddd-engineer/references/naming-rules.md b/.agents/skills/ddd-engineer/references/naming-rules.md new file mode 100644 index 0000000..ce86a58 --- /dev/null +++ b/.agents/skills/ddd-engineer/references/naming-rules.md @@ -0,0 +1,94 @@ +# Naming rules + +Use this reference when creating or renaming classes, messages, commands, queries, services, folders, ports, events, value objects, or tests. + +## General rule + +Names should come from the ubiquitous language, not from technical convenience. Prefer names that answer: + +- What domain concept is this? +- Why does it change? +- Which boundary owns it? + +## Prefer cohesive names + +Good examples: + +- `ConversationMessage`, not `MessageData`. +- `CommunityMembershipRequest`, not `CommunityRequestInfo`. +- `UnreadMessageCounter`, not `CounterHelper`. +- `IdentityPresence`, not `PresenceDto` in domain/application code. + +Avoid names that hide missing design: + +- `Manager` +- `Helper` +- `Utils` +- `Common` +- `Base` +- `Data` +- `Info` +- `Processor` +- generic `Service` + +Use `Service` only when the class is genuinely a domain, application, or infrastructure service and the local codebase uses the suffix intentionally. + +Use `Handler` for framework handlers or event handlers, not as a dumping ground for orchestration and domain decisions. + +Use `Mapper`, `Projector`, `Resource`, `Presenter`, or `Serializer` only at boundaries where shape conversion is the responsibility. + +Use `Policy` for a reusable domain decision. + +Use `Specification` only when the codebase already uses specification-style predicates and the object represents a meaningful domain predicate. + +Use `Factory` only when object creation has real domain complexity or multiple valid construction paths. + +## Messages, commands, and queries + +Boundary messages are translators, not domain models. + +Message names should be tied to intent: + +- `CreateCommunityMessage` +- `SendMessageCommand` +- `FindIdentityQuery` +- `PublishKeychainMessage` + +Avoid vague message names such as: + +- `RequestData` +- `Input` +- `Payload` +- `Params` +- `Body` +- `Props` + +Those names may be acceptable for API DTOs in the presentation layer if the project uses that convention, but they should not leak into application/domain naming. + +If two use cases receive the same primitives but mean different things, create two message classes. Reuse is less important than clear intent at the boundary. + +## Folder and file names + +A folder name should name a layer or business action, not a generic bucket. + +Prefer: + +- `application/create-community/` +- `application/send-message/` +- `domain/value-objects/` +- `infrastructure/postgres-community-repository/` + +Avoid root-level generic buckets inside a module unless the project already has a clear convention: + +- `types/` +- `utils/` +- `dto/` +- `messages/` +- `services/` +- `ports/` +- `helpers/` +- `common/` + +File names should match exported class names unless the project has a clear convention otherwise. + +If a name contains `And`, `Or`, `With`, or several concepts glued together, check whether the class has multiple responsibilities. diff --git a/.agents/skills/ddd-engineer/references/pr-checklist.md b/.agents/skills/ddd-engineer/references/pr-checklist.md new file mode 100644 index 0000000..af928e6 --- /dev/null +++ b/.agents/skills/ddd-engineer/references/pr-checklist.md @@ -0,0 +1,36 @@ +# PR checklist and review response guidance + +Use this reference when preparing a pull request, handoff summary, or response to review comments. + +## Before handoff + +Run the smallest relevant checks first, then broader checks when the change warrants them: + +- Focused unit tests for touched domain/application behavior. +- Acceptance or integration tests for changed routes/workflows/contracts. +- Typecheck/build if the language or framework benefits from it. +- Lint/format when configured. + +If checks fail, report exactly what failed and why it remains unresolved. Do not claim the slice is complete while relevant checks are failing unless clearly blocked. + +## PR description template + +Include: + +- Summary. +- Domain/application boundary decisions. +- API, event, persistence, or public-contract changes. +- Tests run. +- Notes for frontend or downstream consumers when contracts changed. +- Known risks, migrations, or follow-up work. + +## Review comments + +Treat PR comments as actionable engineering feedback unless they are clearly informational. + +When answering review comments: + +- Use the reviewer's language. +- State what changed, or why the current code is intentional. +- Avoid defensive explanations. +- Mention tests or checks when relevant. diff --git a/.agents/skills/ddd-engineer/references/repositories-transactions.md b/.agents/skills/ddd-engineer/references/repositories-transactions.md new file mode 100644 index 0000000..583bda6 --- /dev/null +++ b/.agents/skills/ddd-engineer/references/repositories-transactions.md @@ -0,0 +1,47 @@ +# Repositories, transactions, and concurrency + +Use this reference when changing persistence boundaries, repositories, units of work, transactional use cases, optimistic concurrency, or event persistence. + +## Repository responsibility + +Repositories should express aggregate persistence in domain/application language, not database-table operations. + +Prefer methods such as `save`, `findById`, `findPendingForIdentity`, or existing local equivalents. + +Repositories normally load and save aggregate roots. Do not add repositories for child entities unless the existing codebase has a deliberate exception. + +Do not expose ORM query builders, persistence rows, transaction clients, or DTOs through domain/application repository interfaces unless the local architecture explicitly does so. + +Repository implementations may call `fromPrimitives()` and `toPrimitives()` or equivalent serialization/hydration behavior. Domain and application decision code should not use serialization as a getter shortcut. + +## Unit of work + +Application use cases usually define the transaction boundary. + +A typical command use case loads aggregate roots, calls domain behavior, saves changes, records or publishes resulting events according to local pattern, and returns an explicit result. + +Do not let controllers, mappers, repositories, or infrastructure callbacks decide business transaction boundaries. + +When multiple repositories participate, follow the project’s unit-of-work or transaction pattern instead of passing low-level transaction handles through domain objects. + +## Concurrency + +When aggregate changes can conflict, follow the project’s versioning or optimistic-concurrency pattern. + +Respect versions, revision numbers, event-stream positions, updated-at checks, or expected-version checks when they exist. + +Do not silently overwrite aggregate state. If concurrent modification is a domain-relevant failure, surface it through the project’s error/result pattern or retry policy. + +## Event persistence and outbox + +When events are persisted or published with aggregate changes, keep persistence and dispatch consistent with the project’s unit-of-work or outbox pattern. + +Do not publish integration events directly before the aggregate transaction commits unless the project intentionally does so. + +Outbox rows, transport messages, broker metadata, and retries are infrastructure concerns. Keep them out of aggregate behavior. + +## Query persistence + +Do not force command-side repositories to serve read-heavy query needs if the project has query repositories, read models, projections, or reporting views. + +Query repositories can return read models or resources, but they should not become a place for domain decisions. diff --git a/.agents/skills/ddd-engineer/references/value-objects.md b/.agents/skills/ddd-engineer/references/value-objects.md new file mode 100644 index 0000000..5b083b6 --- /dev/null +++ b/.agents/skills/ddd-engineer/references/value-objects.md @@ -0,0 +1,59 @@ +# Value objects and serialization + +Use this reference when a task touches value objects, primitive conversion, persistence hydration, DTO mapping, authorization, equality, sorting, filtering, or lifecycle decisions. + +## Principles + +Value objects are domain concepts, not decorated primitives. Follow the project's existing value-object library before creating custom wrappers or aliases. + +A value object should expose behavior for decisions that belong to that concept. If calling code must pull out primitive internals to decide something, first ask whether that decision belongs inside the value object, entity, aggregate, or policy. + +## Comparisons + +Prefer behavior over serialization: + +- `identityId.isEqual(otherIdentityId)` over `identityId.toPrimitives() === otherIdentityId.toPrimitives()`. +- `period.includes(timestamp)` over comparing serialized dates. +- `role.can(permission)` over checking a primitive permission array outside the role. +- Numeric value objects should expose comparison methods when ordering or thresholds matter. +- Time and interval value objects should expose date/interval behavior when inclusion, overlap, duration, or ordering matters. + +Do not compare, sort, filter, or branch on serialized primitive internals in domain or application code. + +## Serialization + +Serialization methods such as `toPrimitives()`, `valueOf()`, and `toString()` are boundary tools only. + +Valid places for `toPrimitives()`: + +- Repository persistence. +- Resource or DTO mapping. +- Published event payloads. +- Logs, telemetry, and external SDK calls. +- Contract tests that assert serialized shape. + +Valid places for `fromPrimitives()`: + +- Repository hydration. +- Test fixtures that intentionally build persisted state. +- Message classes only when the primitive payload is explicitly a serialized version of a value object or aggregate and the local pattern allows it. + +Invalid uses: + +- Equality checks. +- Authorization rules. +- Sorting or filtering domain collections. +- Deciding lifecycle transitions. +- Reaching through nested values because adding a method felt slower. + +## Missing behavior + +If behavior is missing, prefer adding it to the owning value object or domain concept instead of creating a helper that knows its internals. + +Avoid generic conversion helpers unless the project already has a boundary-owned mapper pattern and the helper is explicitly about boundary conversion, not domain decision-making. + +## Nullish behavior + +Do not return `Foo | undefined`, `Foo | null`, or broad optional unions from domain/application code as defensive programming. + +If absence is part of the ubiquitous language, model it explicitly with a named domain concept. If the project uses `@haskou/value-objects` and a required value/object is missing, return `NullObject.new(Foo)` instead of widening the return type, and let the appropriate error handler handle that failure path. diff --git a/.agents/skills/ddd-migration/SKILL.md b/.agents/skills/ddd-migration/SKILL.md new file mode 100644 index 0000000..7c67c07 --- /dev/null +++ b/.agents/skills/ddd-migration/SKILL.md @@ -0,0 +1,177 @@ +--- +name: ddd-migration +description: Use when planning or executing a codebase migration toward DDD using the ddd-engineer conventions. Use for architecture blueprinting, bounded-context migration roadmaps, target project structure, coherent workflow slices, durable migration state, and drift-resistant long-running refactors. Do not use for normal feature work or one-off DDD implementation unless the user asks to migrate architecture. +--- + +# DDD migration + +Use this skill to migrate an existing codebase toward Domain-Driven Design incrementally, using the conventions from the `ddd-engineer` skill for implementation details. + +The goal is not to make the smallest possible change. The goal is to deliver coherent, reviewable migration slices that move a business capability toward the target DDD structure without drifting from the plan. + +The primary risk is agent drift during long migrations. Treat durable repository files as the source of truth, not the current conversation. Context compaction can preserve state, but it is not a human-readable migration ledger. Maintain explicit migration state in the repository before and after each migration slice. + +## First actions + +1. Read `AGENTS.md`, repository docs, architecture docs, test docs, and existing migration docs. +2. Read the `ddd-engineer` skill before implementing or reviewing a slice. +3. Look for existing migration state files: + - `docs/ddd-migration/MIGRATION.md` + - `docs/ddd-migration/TARGET_ARCHITECTURE.md` + - `docs/ddd-migration/CONTEXT_MAP.md` + - `docs/ddd-migration/DECISIONS.md` + - `docs/ddd-migration/GLOSSARY.md` + - `docs/ddd-migration/SLICE_LOG.md` +4. If migration state files do not exist, create them before code changes using `references/migration-state-template.md`. +5. Inspect the current architecture before proposing target architecture. +6. Create or update the target DDD project structure before implementation slices using `references/target-architecture.md`. + +Do not start broad refactors before creating or updating the migration state and target architecture. + +## Migration principles + +Migrate by behavior-preserving capability slices. Avoid big-bang rewrites, but also avoid micro-slices that only move one tiny file and do not establish a usable boundary. + +Prefer strangler-style extraction, adapter seams, compatibility layers, contract tests, and target-structure preparation over replacing whole modules at once. + +A migration slice should be coherent enough to leave the codebase in a useful intermediate state. It should have a clear source area, target boundary, acceptance criteria, rollback note, validation level, and expected files/folders. + +Do not introduce DDD patterns everywhere. Introduce a pattern only where it resolves a documented migration pain: scattered business rules, mixed external/persistence/domain models, unclear lifecycle, unsafe cross-module dependency, duplicated validation, or contract drift. + +Do not create empty folders or placeholder layers. Every new folder must contain files needed by the current slice. If the target architecture contains a future folder, document it in `TARGET_ARCHITECTURE.md`; do not create it in the codebase until the first real file belongs there. + +## Target architecture first + +Before implementation work, produce a practical target architecture for the project or selected module. + +The target architecture must include: + +- candidate bounded contexts or modules; +- target folder layout for each migrated area; +- aggregate roots and aggregate boundaries when known; +- application use-case/message boundaries; +- repository and unit-of-work boundaries; +- read model/query-side boundaries when applicable; +- integration boundaries, anticorruption layers, events, jobs, and public contracts; +- compatibility strategy for old entrypoints; +- migration order by business capability. + +The target architecture is a plan, not permission to create empty directories. Keep future structure in documentation until a slice needs the files. + +## Slice sizing + +Use `references/roadmap.md` for sizing. + +Default to a medium coherent slice, not a tiny mechanical one. A good migration slice may touch a full workflow path, such as route/controller -> request/message -> use case/service -> domain model/value object -> repository/mapper -> tests/docs. + +Use tiny slices only for high-risk legacy code, unclear behavior, missing tests, or public-contract risk. + +Use larger slices when the structure would be incoherent if split further, such as introducing a use case plus its message, mapper, tests, and compatibility adapter together. + +## Validation strategy + +Use affected-scope validation by default. Do not run the full repository suite for every slice. + +Choose one validation level for each slice and record it in `SLICE_LOG.md`: + +- L0: no runtime checks; documentation, structure planning, or grep/static inspection only. +- L1: affected tests and checks only: changed test files, nearby tests for touched modules, focused typecheck, or changed-package checks. +- L2: affected module/package validation: module lint/typecheck, relevant integration tests, contract tests, migration seam tests, or package-level checks. +- L3: full repository lint/build/typecheck/test. + +For normal implementation slices, prefer L1. Use L2 when contracts, persistence mapping, module boundaries, shared code, migrations, or integration adapters changed. Use L3 only when the migration milestone is complete, before final PR handoff, after risky cross-cutting changes, after dependency/build-system changes, when affected tests cannot identify the risk, or when repository instructions require it. + +When choosing tests, first identify the changed files and affected behavior, then run the smallest commands that cover that behavior. Record both commands run and intentionally deferred full-suite commands. + +Do not run the full test suite after every slice unless strictly necessary. Batch full validation at migration milestones and at the end of the migration. + +## Anti-drift protocol + +Before each slice: + +1. Read `docs/ddd-migration/MIGRATION.md`, `TARGET_ARCHITECTURE.md`, and `SLICE_LOG.md`. +2. Restate the active slice in one paragraph in the working notes or final handoff. +3. Confirm the slice does not conflict with `DECISIONS.md`. +4. List files expected to change. +5. Choose the validation level before editing. + +During the slice: + +1. Prefer edits that preserve public behavior unless the migration state explicitly says the contract changes. +2. Keep compatibility adapters around until consumers are migrated. +3. Record newly discovered domain terms in `GLOSSARY.md`. +4. Record architectural choices in `DECISIONS.md`, not only in chat. +5. If the target structure changes, update `TARGET_ARCHITECTURE.md` before continuing. +6. If the direction changes, update the migration state before continuing. + +After the slice: + +1. Update `SLICE_LOG.md` with changed files, validation level, checks run, contracts touched, and next slice. +2. Update `MIGRATION.md` status and risk list. +3. Update `TARGET_ARCHITECTURE.md` if actual structure diverged from the plan. +4. Run the selected validation level. +5. Run an empty-directory check. +6. Final response must summarize migration-state updates and exact verification. + +If the conversation has become long, compressed, or ambiguous, stop using memory from the chat as authoritative. Re-read the migration state files and repository docs. + +## Discovery phase + +Use `references/discovery.md` before creating a migration roadmap. + +Produce or update: + +- current architecture map; +- current project structure summary; +- candidate bounded contexts; +- domain vocabulary and conflicting terms; +- public contracts and integration points; +- persistence ownership and transaction boundaries; +- test coverage and missing characterization tests; +- high-risk areas and seams. + +Do not rename or move code during discovery unless the user explicitly asks. + +## Roadmap phase + +Use `references/roadmap.md` to turn discovery and target architecture into a sequence of coherent slices. + +Every roadmap item must include: + +- business capability or workflow; +- current pain; +- target boundary; +- target folder/file structure for the slice; +- migration approach; +- compatibility plan; +- validation level; +- tests required; +- expected files or folders; +- done criteria; +- rollback or stop condition. + +## Implementation phase + +Use `ddd-engineer` conventions for each slice. + +For a slice that changes business behavior, inspect local tests first and add characterization tests before refactoring when behavior is not already protected. + +For a slice that changes a public contract, update contract docs and machine-readable schemas when present. + +For a slice that changes persistence, preserve data compatibility or document the migration requirement explicitly. + +For a slice that extracts a bounded context, create explicit boundaries and anticorruption mappings. Do not reuse foreign entities, DTOs, persistence rows, or enums as domain objects. + +## Done criteria + +A migration slice is not done until: + +- migration state files are updated; +- target architecture remains accurate; +- no empty placeholder folders remain; +- selected validation level has been run or exact blockers are documented; +- contract changes are documented; +- next slice is recorded; +- final response includes active slice id, changed files, validation level, tests/checks, risks, and migration-state updates. + +Use `references/context-management.md` for long-running migration guidance. diff --git a/.agents/skills/ddd-migration/references/context-management.md b/.agents/skills/ddd-migration/references/context-management.md new file mode 100644 index 0000000..640cc4d --- /dev/null +++ b/.agents/skills/ddd-migration/references/context-management.md @@ -0,0 +1,72 @@ +# Context management and drift prevention + +Long DDD migrations are vulnerable to context loss, stale assumptions, and architectural drift. Codex may compact long-running context, but the migration must not depend on the chat transcript as the only memory. + +## Durable state over conversation memory + +Create and maintain `docs/ddd-migration/` as the durable source of truth: + +- `MIGRATION.md`: current goal, scope, status, active slice, constraints, risks, and done criteria. +- `TARGET_ARCHITECTURE.md`: planned DDD structure, aggregate roots, use cases, repositories, adapters, compatibility strategy, and future folders not yet created. +- `CONTEXT_MAP.md`: candidate bounded contexts, upstream/downstream relationships, shared kernel decisions, anticorruption layers, and integration contracts. +- `DECISIONS.md`: append-only architecture decision log. +- `GLOSSARY.md`: ubiquitous language and terms with conflicting meanings. +- `SLICE_LOG.md`: chronological slice log with changed files, validation level, verification, and next steps. + +Before coding after a pause, context compaction, or long tool session, re-read these files. If they are missing or stale, update them before continuing. + +## Compaction-safe slice protocol + +Each slice must be self-contained and recoverable from repository state. + +Record this before coding: + +- slice id and title; +- slice size; +- business capability; +- current source files; +- target boundary; +- target files/folders; +- expected changed files; +- compatibility constraints; +- validation level; +- tests/checks to run; +- non-goals. + +Record this after coding: + +- files changed; +- behavior changed or preserved; +- contracts changed; +- validation level used; +- tests/checks run; +- decisions added; +- risks discovered; +- next recommended slice. + +## Drift alarms + +Pause and re-read migration state if any of these occur: + +- the agent starts changing files outside the active slice; +- a new folder tree is created without files needed for the slice; +- the agent keeps choosing XS slices when the roadmap calls for a workflow boundary; +- the agent runs full validation repeatedly without a milestone, final handoff, repository requirement, or newly identified cross-cutting risk; +- terminology diverges from `GLOSSARY.md`; +- public contracts change unexpectedly; +- a DDD pattern is introduced where the roadmap did not call for one; +- tests are skipped because the architecture is in transition; +- the agent relies on “as discussed earlier” instead of repository files. + +## Handoff format + +The final response for every slice must include: + +- active slice id and size; +- summary; +- migration state files updated; +- changed code files; +- validation level; +- tests/checks run; +- risks or blockers; +- next slice. diff --git a/.agents/skills/ddd-migration/references/discovery.md b/.agents/skills/ddd-migration/references/discovery.md new file mode 100644 index 0000000..758d46f --- /dev/null +++ b/.agents/skills/ddd-migration/references/discovery.md @@ -0,0 +1,42 @@ +# Discovery phase + +Use discovery to understand the system before changing architecture. + +## Inspect + +- Repository instructions and architecture docs. +- Module/folder layout. +- Entry points: controllers, routes, jobs, CLI commands, message consumers, UI flows. +- Business-rule locations. +- Persistence models and ownership. +- Public contracts: API schemas, webhooks, events, websocket messages, SDK DTOs. +- Test coverage: unit, integration, acceptance, contract, characterization. +- External integrations and legacy systems. + +## Find candidate contexts + +Look for clusters of language, workflows, data ownership, and rules that change together. + +Signals: + +- same words with different meanings; +- modules that depend on each other through internal models; +- duplicated validation or lifecycle checks; +- persistence rows passed across feature boundaries; +- controllers or jobs orchestrating business rules from multiple areas; +- public contracts coupled to internal persistence shape. + +## Output + +Update migration state with: + +- current architecture summary; +- candidate contexts; +- context relationships; +- glossary terms; +- public contracts; +- high-risk seams; +- missing tests; +- first safe migration slices. + +Do not perform large code moves in discovery. diff --git a/.agents/skills/ddd-migration/references/implementation-checklist.md b/.agents/skills/ddd-migration/references/implementation-checklist.md new file mode 100644 index 0000000..5174df3 --- /dev/null +++ b/.agents/skills/ddd-migration/references/implementation-checklist.md @@ -0,0 +1,45 @@ +# Implementation checklist + +Use this with the `ddd-engineer` skill for each migration slice. + +## Before editing + +- Read migration state files. +- Confirm active slice id, size, non-goals, expected files, and validation level. +- Read `TARGET_ARCHITECTURE.md` and confirm the slice moves toward it. +- Inspect nearby implementation and tests. +- Add characterization tests only when behavior is underprotected or risky. +- List expected files to change. + +## While editing + +- Keep behavior stable unless the slice explicitly changes it. +- Complete a coherent boundary instead of stopping at a tiny mechanical move. +- Avoid speculative folders and abstractions. +- Preserve public contracts or update docs/schemas/tests. +- Keep compatibility adapters until consumers are migrated. +- Add architecture decisions to `DECISIONS.md`. +- Update `TARGET_ARCHITECTURE.md` if the real structure changes. + +## Validation + +Use the validation level selected in the roadmap. Affected-scope validation is the default. + +- L0: documentation/static inspection only. +- L1: affected tests/checks only: changed test files, nearby tests for touched modules, focused typecheck, or changed-package checks. +- L2: affected module/package validation: module/package lint, typecheck, relevant integration tests, contract tests, migration seam tests, or package-level checks. +- L3: full repository lint/build/typecheck/test. + +Start with the smallest level that covers the risk. Do not repeatedly run L3 after every edit or every slice. Run L3 only at migration completion, milestone boundaries, before final PR handoff, after risky cross-cutting changes, after dependency/build-system changes, when affected tests cannot cover the risk, or when required by repository instructions. + +Before running tests, identify affected files and behavior. Prefer direct test targets, changed-package commands, or local typechecks over full-suite commands. Record full-suite commands that were intentionally deferred. + +If an affected test fails because unrelated existing tests are broken, document the failure and continue only if the active slice remains verifiable another way. + +## Before handoff + +- Run the selected validation level. +- Run empty-directory detection. +- Review diff for out-of-scope changes. +- Update `SLICE_LOG.md`, `MIGRATION.md`, `TARGET_ARCHITECTURE.md`, and related docs. +- Record next slice with suggested size and validation level. diff --git a/.agents/skills/ddd-migration/references/migration-state-template.md b/.agents/skills/ddd-migration/references/migration-state-template.md new file mode 100644 index 0000000..a2618a1 --- /dev/null +++ b/.agents/skills/ddd-migration/references/migration-state-template.md @@ -0,0 +1,157 @@ +# Migration state templates + +Create these files under `docs/ddd-migration/` before starting migration work. + +## MIGRATION.md + +```markdown +# DDD migration + +## Goal + +## Scope + +## Non-goals + +## Current architecture summary + +## Target direction + +## Active slice + +- Id: +- Title: +- Size: XS | S | M | L +- Status: +- Business capability: +- Source area: +- Target boundary: +- Target files/folders: +- Expected files: +- Compatibility constraints: +- Validation level: L0 | L1 | L2 | L3 +- Affected behavior/tests: +- Tests/checks run: +- Full-suite status: not needed | deferred until milestone | required now | completed +- Done criteria: + +## Risks + +## Validation strategy + +- Default level: +- Affected-test selection rule: +- Milestone/full validation trigger: +- Known expensive commands: +- Full-suite deferral policy: + +## Next slices +``` + +## TARGET_ARCHITECTURE.md + +```markdown +# Target DDD architecture + +## Scope + +## Current structure summary + +## Target structure + +```text +``` + +## Bounded contexts / modules + +| Context/module | Owns | Does not own | Integrates with | Boundary style | +| --- | --- | --- | --- | --- | + +## Aggregates and aggregate roots + +| Aggregate root | Owns | Invariants | Repository | Events | +| --- | --- | --- | --- | --- | + +## Application boundaries + +| Use case/workflow | Message/command/query | Inputs | Result | Contracts touched | +| --- | --- | --- | --- | --- | + +## Infrastructure boundaries + +| Adapter/repository/gateway | Domain/application port | External system or persistence model | Mapping strategy | +| --- | --- | --- | --- | + +## Read models / query side + +## Compatibility strategy + +## Migration order + +## Future folders not yet created +``` + +## CONTEXT_MAP.md + +```markdown +# Context map + +## Candidate bounded contexts + +| Context | Capability | Owned model | Persistence owner | Public contracts | Notes | +|---|---|---|---|---|---| + +## Relationships + +| Upstream | Downstream | Relationship | Contract | Translation/ACL | Notes | +|---|---|---|---|---|---| + +## Shared kernel candidates + +## Anticorruption layers +``` + +## DECISIONS.md + +```markdown +# Architecture decisions + +## ADR-0001: + +- Date: +- Status: proposed | accepted | superseded +- Context: +- Decision: +- Consequences: +- Related slices: +``` + +## GLOSSARY.md + +```markdown +# Ubiquitous language + +| Term | Meaning | Context | Conflicts/aliases | Source | +|---|---|---|---|---| +``` + +## SLICE_LOG.md + +```markdown +# Migration slice log + +## Slice <id>: <title> + +- Date: +- Size: XS | S | M | L +- Status: +- Goal: +- Changed files: +- Behavior changed/preserved: +- Contracts changed: +- Validation level: +- Tests/checks: +- Decisions: +- Risks: +- Next slice: +``` diff --git a/.agents/skills/ddd-migration/references/roadmap.md b/.agents/skills/ddd-migration/references/roadmap.md new file mode 100644 index 0000000..9dfce7d --- /dev/null +++ b/.agents/skills/ddd-migration/references/roadmap.md @@ -0,0 +1,127 @@ +# Roadmap phase + +Turn discovery and target architecture into a sequence of coherent, reversible migration slices. + +## Slice sizing + +Avoid both extremes: + +- Do not use huge big-bang slices that rewrite many unrelated workflows. +- Do not use timid micro-slices that only move one helper, create one empty folder, or add one wrapper without completing a usable boundary. + +Default to a medium coherent slice. A slice should usually complete one meaningful migration step for one business capability or workflow. + +A medium slice may include several related files when they form one boundary, for example: + +- route/controller compatibility change; +- boundary message/command/query; +- application use case; +- domain concept/value object/aggregate behavior; +- repository or mapper adjustment; +- resource/DTO mapping; +- focused tests and docs. + +This is acceptable when all files belong to the same workflow and the behavior remains reviewable. + +## Slice sizes + +### XS slice + +Use only for high-risk or unclear legacy areas. + +Examples: + +- add characterization tests around an existing workflow; +- document current behavior and contracts; +- extract one obvious value object used locally. + +### S slice + +Use for a narrow boundary improvement. + +Examples: + +- move one business rule into the owning model and update focused tests; +- introduce one application boundary message plus local conversion; +- isolate one external DTO behind an anticorruption mapper. + +### M slice, default + +Use for normal migration work. + +Examples: + +- migrate one complete command workflow into application/domain boundaries; +- introduce a use case, message, result, mapper, and focused tests for one endpoint; +- define one aggregate root with its core behavior and repository adapter for one workflow; +- split command behavior from read representation for one feature. + +### L slice + +Use only when the target boundary would be incoherent if split. + +Examples: + +- migrate a whole small module around one aggregate root; +- introduce an outbox mapping and integration event for one workflow; +- convert all callers of one old service method to a new use case when partial compatibility would be more dangerous. + +Large slices must have explicit non-goals, expected files, validation level, and rollback notes. + +## Avoid bad slices + +Avoid slices that: + +- create folders without real files; +- rename many concepts without changing boundaries; +- introduce a repository interface without a real persistence boundary; +- add a use case that just passes through to the old service with no migration value; +- run full validation repeatedly without a milestone, final handoff, repository requirement, or newly identified cross-cutting risk; +- change multiple unrelated workflows at once. + +## Roadmap item template + +```markdown +## Slice <id>: <title> + +- Size: XS / S / M / L +- Business capability: +- Current pain: +- Source area: +- Target boundary: +- Target files/folders: +- DDD pattern introduced, if any: +- Compatibility plan: +- Validation level: L0 / L1 / L2 / L3 +- Tests required: +- Public contracts affected: +- Data migration required: +- Done criteria: +- Rollback/stop condition: +- Non-goals: +``` + +## Ordering + +Prefer this order: + +1. Discovery and target architecture. +2. Characterization tests where behavior is unclear. +3. Contract documentation/tests for public boundaries. +4. Boundary mappers/anticorruption seams. +5. Medium workflow slices that establish application/domain structure. +6. Value objects and aggregate behavior for repeated concepts. +7. Repository/unit-of-work cleanup. +8. Read models/query-side separation. +9. Domain/integration events and outbox. +10. Context extraction or service/module split. + +Do not split contexts before contracts, tests, target structure, and seams are understood. + +## Affected-test validation planning + +For each roadmap slice, define the affected validation scope before editing. Prefer direct commands for the changed files, nearby tests, package-level checks, or module-level typechecks. + +Use full-suite validation only for migration completion, milestone boundaries, final PR handoff, repository requirements, dependency/build-system changes, risky cross-cutting changes, or when affected tests cannot reasonably cover the change. + +Record intentionally deferred full-suite validation in `SLICE_LOG.md` so future agents do not mistake omission for forgetfulness. diff --git a/.agents/skills/ddd-migration/references/target-architecture.md b/.agents/skills/ddd-migration/references/target-architecture.md new file mode 100644 index 0000000..f387332 --- /dev/null +++ b/.agents/skills/ddd-migration/references/target-architecture.md @@ -0,0 +1,134 @@ +# Target architecture phase + +Before migrating code, define the target DDD structure for the project or selected module. This prevents the migration from becoming a sequence of disconnected tiny refactors. + +## Purpose + +The target architecture answers: + +- where the domain model will live; +- which bounded contexts or modules own which concepts; +- which aggregate roots own lifecycle and consistency; +- where application use cases/messages live; +- where repositories, mappers, DTOs, events, jobs, and external adapters live; +- how old entrypoints remain compatible during migration; +- which folders should exist now and which are future-only. + +## Required output + +Create or update `docs/ddd-migration/TARGET_ARCHITECTURE.md` with these sections: + +```markdown +# Target DDD architecture + +## Scope + +## Current structure summary + +## Target structure + +```text +<document the intended folders/files here> +``` + +## Bounded contexts / modules + +| Context/module | Owns | Does not own | Integrates with | Boundary style | +| --- | --- | --- | --- | --- | + +## Aggregates and aggregate roots + +| Aggregate root | Owns | Invariants | Repository | Events | +| --- | --- | --- | --- | --- | + +## Application boundaries + +| Use case/workflow | Message/command/query | Inputs | Result | Contracts touched | +| --- | --- | --- | --- | --- | + +## Infrastructure boundaries + +| Adapter/repository/gateway | Domain/application port | External system or persistence model | Mapping strategy | +| --- | --- | --- | --- | + +## Read models / query side + +## Compatibility strategy + +## Migration order + +## Future folders not yet created +``` + +## Rules + +Do not create the full target tree immediately. Document future folders in `TARGET_ARCHITECTURE.md` and create each folder only when the active slice adds a real file there. + +Prefer a structure that matches the existing repository style. Do not force a generic `domain/application/infrastructure` tree if the codebase has an established module convention that can express the same boundaries clearly. + +When the project has no DDD yet, plan an adapter-friendly transition: + +1. keep existing public entrypoints stable; +2. add boundary messages/use cases behind those entrypoints; +3. move business rules toward domain concepts; +4. introduce repositories/adapters where persistence leakage is a problem; +5. split bounded contexts only after contracts and seams are clear. + +## Structure examples + +Use the repository's local conventions first. These are examples, not mandates. + +### Module-oriented backend + +```text +src/<context>/ + domain/ + <aggregate-root>.ts + value-objects/ + events/ + application/ + <workflow>/ + <workflow>.use-case.ts + <workflow>.message.ts + <workflow>.result.ts + infrastructure/ + persistence/ + messaging/ + presentation/ + http/ + resources/ +``` + +### Framework-first backend with restrained DDD + +```text +src/<module>/ + <module>.controller.ts + <module>.service.ts # compatibility orchestration during migration + application/ + <workflow>/ + domain/ + persistence/ + dto/ +``` + +### Frontend feature boundary + +```text +src/features/<feature>/ + domain/ + application/ + api/ + components/ + tests/ +``` + +## Review questions + +Before implementing, check: + +- Is the target structure documented before folders are created? +- Does every planned folder have a reason tied to a workflow or boundary? +- Are aggregate roots and repositories paired intentionally? +- Are public contracts and compatibility adapters identified? +- Is the first implementation slice large enough to establish a usable boundary? diff --git a/.agents/skills/ddd-migration/scripts/find-empty-dirs.sh b/.agents/skills/ddd-migration/scripts/find-empty-dirs.sh new file mode 100755 index 0000000..33b7cf9 --- /dev/null +++ b/.agents/skills/ddd-migration/scripts/find-empty-dirs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="${1:-.}" +find "$ROOT" -type d -empty \ + -not -path '*/.git/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/vendor/*' \ + -not -path '*/dist/*' \ + -not -path '*/build/*' \ + -print diff --git a/.agents/skills/ddd-migration/scripts/init-migration-state.sh b/.agents/skills/ddd-migration/scripts/init-migration-state.sh new file mode 100755 index 0000000..a76ae85 --- /dev/null +++ b/.agents/skills/ddd-migration/scripts/init-migration-state.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="${1:-docs/ddd-migration}" +mkdir -p "$root" + +create_if_missing() { + local file="$1" + local content="$2" + if [ ! -f "$file" ]; then + printf '%s\n' "$content" > "$file" + echo "created $file" + else + echo "exists $file" + fi +} + +create_if_missing "$root/MIGRATION.md" "# DDD migration + +## Goal + +## Scope + +## Non-goals + +## Current architecture summary + +## Target direction + +## Active slice + +- Id: +- Title: +- Size: XS | S | M | L +- Status: +- Business capability: +- Source area: +- Target boundary: +- Target files/folders: +- Expected files: +- Compatibility constraints: +- Validation level: L0 | L1 | L2 | L3 +- Tests/checks: +- Done criteria: + +## Risks + +## Validation strategy + +- Default level: +- Milestone/full validation trigger: +- Known expensive commands: + +## Next slices" + +create_if_missing "$root/TARGET_ARCHITECTURE.md" "# Target DDD architecture + +## Scope + +## Current structure summary + +## Target structure + +\`\`\`text +\`\`\` + +## Bounded contexts / modules + +| Context/module | Owns | Does not own | Integrates with | Boundary style | +| --- | --- | --- | --- | --- | + +## Aggregates and aggregate roots + +| Aggregate root | Owns | Invariants | Repository | Events | +| --- | --- | --- | --- | --- | + +## Application boundaries + +| Use case/workflow | Message/command/query | Inputs | Result | Contracts touched | +| --- | --- | --- | --- | --- | + +## Infrastructure boundaries + +| Adapter/repository/gateway | Domain/application port | External system or persistence model | Mapping strategy | +| --- | --- | --- | --- | + +## Read models / query side + +## Compatibility strategy + +## Migration order + +## Future folders not yet created" + +create_if_missing "$root/CONTEXT_MAP.md" "# Context map + +## Candidate bounded contexts + +| Context | Capability | Owned model | Persistence owner | Public contracts | Notes | +|---|---|---|---|---|---| + +## Relationships + +| Upstream | Downstream | Relationship | Contract | Translation/ACL | Notes | +|---|---|---|---|---|---| + +## Shared kernel candidates + +## Anticorruption layers" + +create_if_missing "$root/DECISIONS.md" "# Architecture decisions + +## ADR-0001: Initial migration state + +- Date: +- Status: proposed +- Context: +- Decision: +- Consequences: +- Related slices:" + +create_if_missing "$root/GLOSSARY.md" "# Ubiquitous language + +| Term | Meaning | Context | Conflicts/aliases | Source | +|---|---|---|---|---|" + +create_if_missing "$root/SLICE_LOG.md" "# Migration slice log + +## Slice <id>: <title> + +- Date: +- Size: XS | S | M | L +- Status: +- Goal: +- Changed files: +- Behavior changed/preserved: +- Contracts changed: +- Validation level: +- Tests/checks: +- Decisions: +- Risks: +- Next slice:" diff --git a/.agents/skills/ddd-migration/scripts/read-migration-state.sh b/.agents/skills/ddd-migration/scripts/read-migration-state.sh new file mode 100755 index 0000000..c3ec9de --- /dev/null +++ b/.agents/skills/ddd-migration/scripts/read-migration-state.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="${1:-docs/ddd-migration}" +for file in MIGRATION.md TARGET_ARCHITECTURE.md CONTEXT_MAP.md DECISIONS.md GLOSSARY.md SLICE_LOG.md; do + path="$ROOT/$file" + if [ -f "$path" ]; then + echo "===== $path =====" + sed -n '1,220p' "$path" + echo + else + echo "missing $path" + fi +done diff --git a/.agents/skills/ddd-migration/scripts/scan-migration-seams.sh b/.agents/skills/ddd-migration/scripts/scan-migration-seams.sh new file mode 100755 index 0000000..4375d7a --- /dev/null +++ b/.agents/skills/ddd-migration/scripts/scan-migration-seams.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="${1:-.}" +cd "$ROOT" + +echo "== Repository guidance ==" +find .. . -maxdepth 3 \( -name AGENTS.md -o -name AGENTS.override.md -o -name CONTRIBUTING.md -o -iname '*architecture*' -o -iname '*context*' \) -type f 2>/dev/null | sort | sed 's#^./##' | head -80 + +echo +printf '== Possible entrypoints ==\n' +find . -type f \( -iname '*controller*' -o -iname '*route*' -o -iname '*handler*' -o -iname '*consumer*' -o -iname '*job*' -o -iname '*command*' \) 2>/dev/null | sed 's#^./##' | head -120 + +echo +printf '== Possible contracts ==\n' +find . -type f \( -iname '*openapi*' -o -iname '*.proto' -o -iname '*schema*' -o -iname '*event*' -o -iname '*webhook*' \) 2>/dev/null | sed 's#^./##' | head -120 + +echo +printf '== Possible tests ==\n' +find . -type f \( -iname '*test*' -o -iname '*spec*' -o -iname '*.feature' \) 2>/dev/null | sed 's#^./##' | head -120 diff --git a/.agents/skills/ddd-migration/scripts/snapshot-structure.sh b/.agents/skills/ddd-migration/scripts/snapshot-structure.sh new file mode 100755 index 0000000..d6a52a0 --- /dev/null +++ b/.agents/skills/ddd-migration/scripts/snapshot-structure.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="${1:-.}" +max_depth="${2:-4}" + +if command -v tree >/dev/null 2>&1; then + tree -a -L "$max_depth" -I '.git|node_modules|vendor|dist|build|coverage|.next|.turbo|target|__pycache__' "$root" +else + find "$root" -maxdepth "$max_depth" \ + \( -path '*/.git' -o -path '*/node_modules' -o -path '*/vendor' -o -path '*/dist' -o -path '*/build' -o -path '*/coverage' -o -path '*/.next' -o -path '*/.turbo' -o -path '*/target' -o -path '*/__pycache__' \) -prune \ + -o -print | sort +fi diff --git a/.agents/skills/ddd-migration/scripts/suggest-affected-checks.sh b/.agents/skills/ddd-migration/scripts/suggest-affected-checks.sh new file mode 100755 index 0000000..bae9ffd --- /dev/null +++ b/.agents/skills/ddd-migration/scripts/suggest-affected-checks.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_ref="${1:-HEAD}" + +echo "Changed files since ${base_ref}:" +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git diff --name-only "${base_ref}" -- || true +else + echo "Not inside a git repository." +fi + +echo +cat <<'EOF' +Suggested affected-scope validation strategy: +1. Run tests directly matching changed test files. +2. Run nearby tests for changed source modules. +3. Run package/module typecheck or lint for touched package boundaries. +4. Run relevant integration/contract tests only when public contracts, persistence, adapters, or events changed. +5. Defer full repository validation until milestone/final handoff unless the change is cross-cutting or repository rules require it. +EOF diff --git a/.agents/skills/haskou-flow/SKILL.md b/.agents/skills/haskou-flow/SKILL.md new file mode 100644 index 0000000..798d96e --- /dev/null +++ b/.agents/skills/haskou-flow/SKILL.md @@ -0,0 +1,103 @@ +--- +name: haskou-flow +description: "Use @haskou/flow when implementing, refactoring, reviewing, or testing TypeScript async coordination: concurrency limits, queues, rate limiting, timeouts, retries, racing, abort signals, scheduled jobs, debouncing, throttling, circuit breakers, fallback chains, or Flow/FlowPipeline composition." +--- + +# @haskou/flow + +Use `@haskou/flow` to make promise-producing work run with explicit coordination rules instead of ad hoc timers, booleans, shared counters, or retry loops. + +The npm package documentation checked for this skill is `@haskou/flow@0.1.1`. The README points to `https://haskou.github.io/flow/`, but that page returned GitHub Pages "Site not found" when this skill was written. The package README and upstream repository docs were used instead. + +## First Steps + +1. Check whether the project already depends on `@haskou/flow` and `@haskou/value-objects`. +2. Import flow-control classes from `@haskou/flow`. +3. Use `Duration` from `@haskou/value-objects` for time inputs unless existing code consistently uses millisecond numbers at infrastructure boundaries. +4. Keep domain behavior in the domain model. Use flow classes to coordinate execution, not to hide business rules. +5. Load [api-reference.md](references/api-reference.md) when exact constructors, methods, option classes, or error classes matter. + +```typescript +import { Flow, RetryOptions, Semaphore } from '@haskou/flow'; +import { Duration } from '@haskou/value-objects'; + +const result = await new Flow() + .task((signal) => fetchSomething({ signal })) + .timeout(Duration.fromSeconds(3)) + .retry(new RetryOptions(3)) + .limit(new Semaphore(2)) + .run(); +``` + +## Choose The Class + +- Use `Semaphore` when unrelated code paths must share a fixed capacity and callers may wait for a permit. +- Use `Queue` when submitted tasks should wait their turn and each caller receives its own result. +- Use `RateLimiter` when task starts must be spaced by a fixed interval, often for provider/API quotas. +- Use `Timeout` or `Flow.timeout()` when slow work must reject and receive an `AbortSignal`. +- Use `Retrier` or `Flow.retry()` for transient failures where retrying the same operation is valid. +- Use `CircuitBreaker` around unstable external dependencies after repeated failures should block more calls temporarily. +- Use `FallbackChain` when ordered sources can produce the same value, such as memory, cache, database, then remote API. +- Use `Racer` when alternative sources can run concurrently and the first successful value should win. +- Use `Abortable` when a caller must cancel running work externally. +- Use `Scheduler` for repeated background work that must not overlap with a previous run. +- Use `Debouncer` to collapse bursts into one latest operation. +- Use `Throttler` when every submitted task should run, but starts must be spaced. +- Use `Flow` when one task needs several controls in a readable chain. +- Use `FlowPipeline` only for lower-level composition of classes exposing `run(task)`; prefer `Flow` for new code that needs timeout, retry, racing, or abortability. + +## Composition Rules + +Make ordering visible. `Flow` steps are applied in chain order around the task, so put the controls in the order that expresses the intended execution boundary. + +```typescript +const user = await new Flow() + .task((signal) => remoteUsers.get(id, { signal })) + .timeout(Duration.fromSeconds(2)) + .retry(new RetryOptions(3, Duration.fromMilliseconds(50))) + .rateLimit(userProviderRateLimiter) + .circuitBreaker(userProviderBreaker) + .run(); +``` + +Share long-lived controllers when the policy is shared. For example, a provider-wide `Semaphore`, `RateLimiter`, or `CircuitBreaker` should be injected/reused instead of constructed inside every call. + +Use `FlowPipeline` for reusable `run(task)` chains: + +```typescript +const pipeline = new FlowPipeline() + .through(queue) + .through(rateLimiter) + .through(circuitBreaker); + +await pipeline.run(() => callProvider()); +``` + +## Error Handling + +Catch library-specific errors only when the caller can recover from that flow-control decision. Let task errors propagate unless the application boundary has a clear mapping. + +```typescript +try { + await breaker.run(() => callProvider()); +} catch (error) { + if (error instanceof CircuitBreakerOpenError) { + return cachedFallback; + } + + throw error; +} +``` + +Important recoverable errors include `TimeoutError`, `CircuitBreakerOpenError`, `QueueClearedError`, `FlowCancelledError`, `FlowAbortedError`, `FallbackChainExhaustedError`, `RacerExhaustedError`, and `SchedulerAlreadyRunningError`. + +## Testing Guidance + +Test the behavior at the coordination boundary: + +- For queues, semaphores, throttlers, and rate limiters, assert ordering, active counts, waiting counts, or elapsed scheduling behavior with fake timers when the project already uses them. +- For timeouts and abortability, assert that the task receives an `AbortSignal` and that the expected error class is thrown. +- For retries and circuit breakers, assert attempt counts and state transitions through the public API. +- For fallback chains and racers, assert source order or first-success behavior without depending on primitive internals of value objects. + +Avoid testing implementation details such as internal timers or private counters unless the production API exposes no safer observation point. diff --git a/.agents/skills/haskou-flow/agents/openai.yaml b/.agents/skills/haskou-flow/agents/openai.yaml new file mode 100644 index 0000000..5af427a --- /dev/null +++ b/.agents/skills/haskou-flow/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "@haskou/flow" + short_description: "Use @haskou/flow for async coordination in TypeScript." + default_prompt: "Use @haskou/flow to add concurrency, timeout, retry, rate limiting, circuit breaker, fallback, or scheduling behavior to this TypeScript code." diff --git a/.agents/skills/haskou-flow/references/api-reference.md b/.agents/skills/haskou-flow/references/api-reference.md new file mode 100644 index 0000000..94bdaf8 --- /dev/null +++ b/.agents/skills/haskou-flow/references/api-reference.md @@ -0,0 +1,183 @@ +# @haskou/flow API Reference + +This reference summarizes the package README and upstream repository docs for `@haskou/flow@0.1.1`. + +## Installation + +```bash +npm install @haskou/flow +yarn add @haskou/flow +``` + +Import flow classes from `@haskou/flow`. Import `Duration` from `@haskou/value-objects` for readable time values. + +## Flow + +`Flow<T = unknown>` builds a runnable async task with visible execution rules. + +- Constructor: `new Flow()`. +- Task source: `task(task)` or `race(racer)`. +- Steps: `timeout(duration)`, `retry(options)`, `limit(semaphore)`, `queue(queue)`, `rateLimit(rateLimiter)`, `throttle(throttler)`, `circuitBreaker(circuitBreaker)`, `abortable(abortable)`, `through(controller)`. +- Run: `run()`. +- Throws: `FlowTaskMissingError`, task errors, and errors from configured steps. +- Notes: `task()` may receive an `AbortSignal`; `task()` returns a typed `Flow<TResult>`. + +## Concurrency + +### Semaphore + +`Semaphore` limits concurrent access to a shared capacity. + +- Constructor: `new Semaphore(permits: number)`. +- Methods: `acquire(signal?)`, `tryAcquire()`, `runExclusive(task)`, `run(task)`, `getCapacity()`, `getAvailablePermits()`, `getWaitingCount()`. +- `SemaphorePermit.release()` releases a permit exactly once. +- Throws: `InvalidSemaphorePermitsError`, `SemaphoreReleasedError`, `FlowAbortedError` for aborted waiters. +- Notes: `runExclusive()`/`run()` release in `finally`; waiters resume FIFO. + +### Queue + +`Queue` runs submitted tasks with bounded concurrency. + +- Constructor: `new Queue(options = new QueueOptions())`. +- Options: `new QueueOptions(concurrency = Concurrency.DEFAULT)`, `QueueOptions.withConcurrency(concurrency)`. +- Methods: `enqueue(task)`, `run(task)`, `clear(error?)`, `waitUntilIdle()`, `getConcurrency()`, `getPendingCount()`, `getActiveCount()`. +- Throws: `InvalidQueueConcurrencyError`, `QueueClearedError`, task errors. +- Notes: `clear()` rejects pending work only; active tasks continue. + +## Timing + +### RateLimiter + +`RateLimiter` spaces queued task starts by a fixed interval. + +- Constructor: `new RateLimiter(options: RateLimiterOptions)`. +- Options: `new RateLimiterOptions(interval, queueOptions = new QueueOptions())`; interval accepts milliseconds, `Duration`, or `RateLimiterInterval`. +- Methods: `schedule(task)`, `run(task)`, `waitUntilIdle()`. +- Throws: `InvalidRateLimiterIntervalError`, task errors. +- Notes: first task runs immediately; later tasks wait since the previously reserved start. + +### Timeout + +`Timeout` rejects work that does not finish before the configured duration. + +- Constructor: `new Timeout(duration: number | Duration | TimeoutDuration)`. +- Method: `run(task, signal?)`. +- Throws: `InvalidTimeoutDurationError`, `TimeoutError`, `FlowAbortedError`, task errors. +- Notes: number inputs are milliseconds; timeout aborts the task signal before rejecting. + +### Scheduler + +`Scheduler` runs a periodic task without overlapping executions. + +- Constructor: `new Scheduler(options: SchedulerOptions)`. +- Options: `new SchedulerOptions(interval, task, errorPolicy = SchedulerErrorPolicy.THROW, semaphore = new Semaphore(1))`. +- Methods: `start()`, `stop()`, `isRunning()`, `runOnce()`, `assertStopped()`. +- `SchedulerErrorPolicy`: `THROW`, `SWALLOW`, with `isThrow()` and `isSwallow()`. +- Throws: `InvalidSchedulerIntervalError`, `SchedulerAlreadyRunningError`, task errors when policy is `THROW`. +- Notes: `start()` and `stop()` are idempotent; `runOnce()` returns `false` when another run is active. + +### Debouncer + +`Debouncer<T>` delays execution until calls stop arriving for the configured delay. + +- Constructor: `new Debouncer<T>(delay: number | Duration | DebounceDelay)`. +- Methods: `run(task)`, `cancel(error?)`. +- Throws: `InvalidDebouncerDelayError`, `FlowCancelledError`, latest task errors. +- Notes: only the latest task function executes; all pending callers resolve/reject with the same result/error. + +### Throttler + +`Throttler` runs every task, one at a time, keeping at least the configured interval between starts. + +- Constructor: `new Throttler(interval: number | Duration | ThrottleInterval)`. +- Methods: `run(task)`, `waitUntilIdle()`. +- Throws: `InvalidThrottlerIntervalError`, task errors. +- Notes: first task runs immediately; unlike `Debouncer`, intermediate calls are not dropped. + +## Resilience + +### Retrier + +`Retrier` retries a failing task until it succeeds or attempts are exhausted. + +- Constructor: `new Retrier(options = new RetryOptions())`. +- Options: `new RetryOptions(attempts = RetryAttempts.ONCE, delay = RetryDelay.none())`. +- Method: `run(task, signal?)`. +- Throws: `InvalidRetryAttemptsError`, `InvalidRetryDelayError`, `FlowAbortedError`, and the last task error. + +### CircuitBreaker + +`CircuitBreaker` rejects work after repeated failures and later allows half-open probe calls. + +- Constructor: `new CircuitBreaker(options: CircuitBreakerOptions)`. +- Options: `new CircuitBreakerOptions(failureThreshold, recoveryTimeout, successThreshold = 1, halfOpenMaxConcurrent = 1)`. +- Methods: `execute(task)`, `run(task)`, `reset()`, `getState()`, `getFailureCount()`. +- Throws: `CircuitBreakerOpenError`, validation errors, task errors. +- State transitions: `closed` opens when failures reach threshold; `open` becomes `half-open` after recovery timeout; enough half-open successes close it; any half-open failure opens it. +- Notes: extra half-open probes throw `CircuitBreakerOpenError`. + +### CircuitBreakerState + +State values are `closed`, `open`, and `half-open`. Use the public state/value-object behavior exposed by the package instead of comparing extracted primitives in domain/application code. + +## Fallbacks And Racing + +### FallbackChain + +`FallbackChain<T>` resolves the first non-nullish value from ordered sources. + +- Constructor: `new FallbackChain<T>(options = FallbackChainOptions.default())`. +- Methods: `try(attempt)`, `onError(handler)`, `run()`. +- Attempt wrapper: `new FallbackAttempt(attempt)` with `run()`. +- Throws: `FallbackChainExhaustedError`, attempt errors unless catch mode is enabled. +- Notes: only `null` and `undefined` mean unavailable; `false`, `0`, and `''` are valid results. Use `FallbackChainOptions.catchingErrors()` for best-effort fallback and `onError()` for observability. + +### Racer + +`Racer` runs candidates and resolves with the first successful value. + +- Methods: `task(task)`, `add(task)`, `run(signal?)`. +- Throws: `RacerExhaustedError` if no candidate succeeds. +- Notes: combine with `Flow.retry()` when the race itself should be retried. + +## Abortability + +`Abortable` owns an `AbortController` for cancellable work. + +- Constructor: `new Abortable()`. +- Methods: `abort()`, `getSignal()`, `run(task)`. +- Throws: `FlowAbortedError`, task errors. +- Notes: use `Flow.abortable(abortable)` when external callers need to cancel a running flow. + +## FlowPipeline + +`FlowPipeline` composes objects exposing `run(task)`. + +- Constructor: `new FlowPipeline()`. +- Methods: `through(controller)`, `run(task)`. +- Compatible built-ins: `Semaphore`, `Queue`, `RateLimiter`, `Throttler`, `CircuitBreaker`. +- Notes: empty pipelines run the task directly; prefer `Flow` for new code needing timeout, retry, racing, or abortability. + +## Configuration Values + +Most constructors accept numbers for convenience. Time numbers are milliseconds. Prefer explicit values when the policy is named or reused. + +- Concurrency: `Concurrency`, `SemaphoreCapacity`. +- Timing: `RateLimiterInterval`, `SchedulerInterval`, `DebounceDelay`, `ThrottleInterval`, `TimeoutDuration`, `CircuitBreakerRecoveryTimeout`, `RetryDelay`. +- Retry: `RetryAttempts`. + +## Error Classes + +All library-specific errors extend `FlowError`. + +- `SemaphoreReleasedError`: permit released more than once. +- `QueueClearedError`: pending queue work cleared before start. +- `CircuitBreakerOpenError`: breaker rejected execution. +- `FlowCancelledError`: pending debounce work cancelled. +- `FlowAbortedError`: running work aborted. +- `FlowTaskMissingError`: `Flow.run()` called without a task. +- `FallbackChainExhaustedError`: no fallback attempt produced a value. +- `TimeoutError`: work exceeded timeout. +- `RacerExhaustedError`: no racer candidate succeeded. +- `SchedulerAlreadyRunningError`: stopped state required but scheduler is running. +- Validation errors: `InvalidSemaphorePermitsError`, `InvalidQueueConcurrencyError`, `InvalidRateLimiterIntervalError`, `InvalidSchedulerIntervalError`, `InvalidDebouncerDelayError`, `InvalidThrottlerIntervalError`, `InvalidTimeoutDurationError`, `InvalidRetryAttemptsError`, `InvalidRetryDelayError`, `InvalidCircuitBreakerFailureThresholdError`, `InvalidCircuitBreakerRecoveryTimeoutError`, `InvalidCircuitBreakerSuccessThresholdError`, `InvalidCircuitBreakerHalfOpenMaxConcurrentError`. diff --git a/.agents/skills/haskou-value-objects/SKILL.md b/.agents/skills/haskou-value-objects/SKILL.md new file mode 100644 index 0000000..688ca82 --- /dev/null +++ b/.agents/skills/haskou-value-objects/SKILL.md @@ -0,0 +1,496 @@ +--- +name: haskou-value-objects +description: Use @haskou/value-objects correctly in TypeScript DDD projects. Enforce Value Object behavior, Enum-style domain values, Demeter-friendly comparisons, serialization boundaries, SOLID, cohesive naming, and project naming conventions. +--- + +# @haskou/value-objects Skill + +Use this skill whenever touching TypeScript code that creates, compares, serializes, hydrates, validates, or tests Value Objects using `@haskou/value-objects`. + +This project works with DDD. Treat Value Objects as domain behavior, not decorated primitives. The whole point is to stop spraying strings, numbers, enums, and bags of props through the domain like confetti at a legacy wedding. + +## Core rule + +Do not unwrap a Value Object to make a domain decision. + +Use the methods exposed by the object: + +- `isEqual(other)` and `isNotEqual(other)` for equality. +- Domain-specific predicates like `isPaid()`, `isDraft()`, `canBeCancelled()`, `belongsToCustomer(customerId)`, etc. when the comparison has business meaning. +- `isGreaterThan`, `isGreaterOrEqualThan`, `isLessThan`, `isLessOrEqualThan`, `isZero` for numeric Value Objects. +- `add`, `subtract`, `multiply`, `divide` for numeric operations. +- `isBefore`, `isAfter`, `isBeforeOrEqual`, `isAfterOrEqual`, `isSameDay`, `isSameMonth`, `isSameYear` for timestamps. +- `includes`, `getOverlappingInterval`, `getDuration`, `getStart`, `getEnd` for timestamp intervals. +- Specific accessors like `getLatitude`, `getLongitude`, `getMonth`, `getYear`, `getDay`, `getHours`, `getMinutes`, etc. + +Bad: + +```ts +const isSameUser = user.id.toPrimitives() === otherUser.id.toPrimitives(); +const isSameEmail = user.email.valueOf() === email.valueOf(); +const startsBefore = interval.toPrimitives().start < timestamp.valueOf(); +const isPaid = order.status.valueOf() === 'paid'; +``` + +Good: + +```ts +const isSameUser = user.id.isEqual(otherUser.id); +const isSameEmail = user.email.isEqual(email); +const includesTimestamp = interval.includes(timestamp); +const isPaid = order.status.isPaid(); +``` + +## Boundary rule + +Primitives live at boundaries. Value Objects live in the domain. + +Application messages, HTTP DTOs, CLI inputs, event payloads, persistence rows, and OpenAPI schemas receive or expose primitives. Application services convert primitives into Value Objects before calling domain constructors or domain methods. + +Domain constructors must receive Value Objects, not primitive `props` bags. + +Bad: + +```ts +new User({ + id: '507f1f77bcf86cd799439011', + email: 'user@example.com', +}); +``` + +Good: + +```ts +new User(new UserId('507f1f77bcf86cd799439011'), new Email('user@example.com')); +``` + +## Serialization and hydration + +`toPrimitives()` and `fromPrimitives()` are for crossing boundaries only: + +- persistence mappers, +- DTO mapping, +- published events, +- OpenAPI/API responses, +- test snapshots of serialized contracts. + +Never use `toPrimitives()` for equality, ordering, branching, filtering, or deciding business rules. That is a Demeter violation and a fast lane back to primitive obsession, which humanity apparently keeps reinventing for sport. + +`valueOf()` and `toString()` are also boundary tools. Use them when writing primitives out to persistence, logs, telemetry, API responses, or external libraries. Do not use them as the default comparison mechanism inside domain or application code. + +For `Enum`-style Value Objects, expose `fromPrimitives(value)` or another explicit boundary factory when hydration needs to accept a raw string or number. Keep invalid-value rejection inside the Value Object constructor/factory, not scattered through controllers, repositories, handlers, or other procedural junk drawers. + +## Preferred imports + +Import from the actual package name unless the project already has a local wrapper or path alias: + +```ts +import { + Email, + Enum, + PositiveNumber, + ShortId, + Timestamp, +} from '@haskou/value-objects'; +``` + +Follow existing project import conventions first. Do not invent a parallel Value Object abstraction when the package already covers the case. + +If the codebase has a local `EnumValueObject` wrapper or alias, treat it as an `Enum`-style Value Object and apply the same rules. Do not introduce both `Enum` and `EnumValueObject` patterns in the same bounded context unless the existing codebase already made that mess and you are containing it, not feeding it. + +## Built-in Value Objects and methods to prefer + +Common primitives: + +- `ValueObject<T>`: base class for single primitive Value Objects. +- `StringValueObject`: string validation and `isEmpty()`. +- `NumberValueObject`: numeric comparisons and arithmetic. +- `Integer`: whole numbers. +- `PositiveNumber`: numbers greater than zero. +- `Email`: validated email values. +- `Color`: validated hex colors, predefined colors, and case-insensitive `isEqual`. + +Finite domain values: + +- `Enum<T>`: base class for finite, validated primitive sets such as statuses, types, modes, roles, categories, frequencies, or provider codes. +- Use `getValues()` to declare the allowed primitive values. +- Put predicates and transitions on the concrete class, not in random `switch` statements. + +Identifiers: + +- `ShortId.generate()` for MongoDB ObjectId-style ids. +- `UUID.generate()` for UUID v4 ids. +- Compare ids with `isEqual`, never by string extraction. + +Time: + +- `Timestamp.now()`, `Timestamp.new(value)`, `Timestamp.fromSeconds(value)`. +- Timestamp comparison and arithmetic methods instead of primitive math. +- `CalendarDay`, `Day`, `DayOfWeek`, `Month`, `MonthOfYear`, `Year`, `Hour`, `Duration` for explicit temporal concepts. +- `TimestampInterval.fromPrimitives()` and `toPrimitives()` only for serialization/hydration. + +Coordinates: + +- `Latitude`, `Longitude`, `Coordinates`. +- Use `Coordinates.fromString(value)` when parsing external strings. +- Use `getLatitude()` and `getLongitude()` when domain behavior needs the coordinate parts. + +Collections: + +- `UniqueObjectArray<T>` expects items with `isEqual(item)`. +- Use it for uniqueness by Value Object behavior instead of deduping with primitives. +- Prefer `includes`, `push`, `remove`, `length`, and `toArray()` over hand-rolled array rituals. + +Hashes, media, and crypto: + +- Use `MD5Hash`, `SHA256Hash`, `SHA512Hash`, `Media`, `KeyPair`, `EncryptedKeyPair`, `PrivateKey`, `EncryptedPrivateKey`, `PublicKey`, `Signature`, etc. when the domain actually needs those concepts. +- Use `Hash.from(...)`, `toBase64()`, `Media.getBuffer()`, `Media.getSize()`, and `Media.getBase64()` at boundaries or crypto/media behavior points. +- Do not leak crypto payload internals across the domain. Keep encryption/signing behavior on the relevant objects. + +Nullish behavior: + +- `ValueObject` supports the library's Null Object behavior for `null` or `undefined` inputs. +- Do not return `Foo | undefined`, `Foo | null`, or broad optional unions from domain/application code as defensive programming. +- If a required value/object is missing, return `NullObject.new(Foo)` instead of widening the return type, and let the appropriate error handler handle that failure path. +- Do not pass `null` or `undefined` intentionally to mean a business state unless the existing design explicitly models that pattern. +- Prefer explicit domain concepts such as `OptionalDeliveryDate`, `UnassignedOwner`, or nullable fields at the boundary when absence is part of the language. + +## Enum / EnumValueObject-style Value Objects + +Use an `Enum`-style Value Object when the domain concept has a closed set of valid primitive values and may grow behavior over time. + +Good candidates: + +- `OrderStatus`, `PaymentStatus`, `InvoiceStatus`. +- `CurrencyCode`, `CountryCode`, `LocaleCode` when the domain cares about the allowed set. +- `NotificationChannel`, `ShipmentProvider`, `BillingPeriod`, `UserRole`. +- `PlanType`, `FeatureFlag`, `PermissionScope`, `RetryStrategy`. + +Bad candidates: + +- Free text fields. +- Unbounded external provider values that are not controlled by the domain. +- UI labels, translated strings, icons, colors, CSS classes, or display names. That stuff belongs in presenters/mappers, because apparently even strings need costumes now. + +The package exports `Enum`. Some projects may call the same idea `EnumValueObject`. The rule is the same: the class owns valid values, comparison, predicates, and domain behavior. + +Bad: + +```ts +export enum OrderStatus { + DRAFT = 'draft', + PAID = 'paid', + CANCELLED = 'cancelled', +} + +if (order.status === OrderStatus.PAID) { + // domain decision outside the domain model +} + +if (order.status === 'cancelled') { + // raw string cosplay +} +``` + +Good: + +```ts +import { Enum } from '@haskou/value-objects'; + +enum OrderStatusPrimitive { + DRAFT = 'draft', + PAID = 'paid', + CANCELLED = 'cancelled', + SHIPPED = 'shipped', +} + +export class OrderStatus extends Enum<string> { + static readonly DRAFT = new OrderStatus(OrderStatusPrimitive.DRAFT); + static readonly PAID = new OrderStatus(OrderStatusPrimitive.PAID); + static readonly CANCELLED = new OrderStatus(OrderStatusPrimitive.CANCELLED); + static readonly SHIPPED = new OrderStatus(OrderStatusPrimitive.SHIPPED); + + private constructor(value: string) { + super(value); + } + + static fromPrimitives(value: string): OrderStatus { + return new OrderStatus(value); + } + + getValues(): string[] { + return Object.values(OrderStatusPrimitive); + } + + isDraft(): boolean { + return this.isEqual(OrderStatus.DRAFT); + } + + isPaid(): boolean { + return this.isEqual(OrderStatus.PAID); + } + + isCancelled(): boolean { + return this.isEqual(OrderStatus.CANCELLED); + } + + canBePaid(): boolean { + return this.isDraft(); + } + + canBeShipped(): boolean { + return this.isPaid(); + } + + pay(): OrderStatus { + if (this.canBePaid() === false) { + throw new Error('Only draft orders can be paid'); + } + + return OrderStatus.PAID; + } +} +``` + +Rules: + +- Keep the primitive enum, literal list, or allowed-values object private to the Value Object module unless the existing codebase already exposes it as contract. +- Prefer static readonly instances for named values. +- Provide `fromPrimitives(value)` when external layers hydrate from strings/numbers. +- Do not export raw enum primitives as the domain API. +- Do not compare `status.valueOf()` to strings inside entities, aggregates, application services, policies, or tests. +- Do not switch on `status.valueOf()` in domain code. Add behavior to the Value Object or to the aggregate. +- Use semantic methods: `isPaid`, `isFinal`, `canTransitionTo`, `canBeCancelled`, `requiresInvoice`, etc. +- Keep transition rules close to the aggregate when they require aggregate state. Keep pure state questions on the Value Object. +- Throw a domain-specific error when an invalid transition matters to the domain. Let constructor validation reject impossible enum values. + +Bad transition logic: + +```ts +if (order.status.valueOf() === 'draft') { + order.status = OrderStatus.PAID; +} +``` + +Good transition logic: + +```ts +order.pay(); +``` + +Inside the aggregate: + +```ts +pay(): void { + this.status = this.status.pay(); + this.paidAt = Timestamp.now(); +} +``` + +## Enum labels, mappings, and external contracts + +Display metadata is not the enum. + +Bad: + +```ts +if (order.status.valueOf() === 'paid') { + return 'Paid order'; +} +``` + +Good at a presentation boundary: + +```ts +const orderStatusLabels: Record<string, string> = { + draft: 'Draft', + paid: 'Paid', + cancelled: 'Cancelled', + shipped: 'Shipped', +}; + +return { + status: order.status.valueOf(), + statusLabel: orderStatusLabels[order.status.valueOf()], +}; +``` + +This is acceptable because it is serialization/presentation code. Do not copy this pattern into domain behavior unless you enjoy debugging business logic hidden inside translation glue. + +When external contracts need strict enum schemas, define the API/OpenAPI enum at the boundary and map it to the domain `Enum` Value Object. Do not make the domain depend on generated OpenAPI enums. + +## Custom Value Objects + +When the domain needs a concept that is not covered by the library, create a cohesive class that extends the closest built-in base class. + +Example: + +```ts +import { StringValueObject } from '@haskou/value-objects'; + +export class UserName extends StringValueObject { + private static readonly MAX_LENGTH = 80; + + constructor(value: string | StringValueObject) { + super(value, UserName.MAX_LENGTH); + } +} +``` + +Rules: + +- The class name must express one domain concept. +- Put validation inside the constructor or explicit factory. +- Put domain-specific behavior inside the Value Object. +- Do not create `*Utils`, `*Helper`, or primitive comparison functions when behavior belongs in the object. +- Preserve immutability. Methods should return new Value Objects unless the library type explicitly models mutation. +- Prefer named factories like `fromPrimitives`, `fromString`, `fromSeconds`, `fromTimestamp`, `generate`, or `now` when construction needs intent. + +## Equality inside entities and aggregates + +Entities and aggregates should expose meaningful behavior instead of forcing callers to inspect ids or status values. + +Bad: + +```ts +if (order.customerId.valueOf() === customer.id.valueOf()) { + // ... +} + +if (order.status.valueOf() === 'paid') { + // ... +} +``` + +Good: + +```ts +if (order.belongsToCustomer(customer.id)) { + // ... +} + +if (order.isPaid()) { + // ... +} +``` + +Inside the entity: + +```ts +belongsToCustomer(customerId: CustomerId): boolean { + return this.customerId.isEqual(customerId); +} + +isPaid(): boolean { + return this.status.isPaid(); +} +``` + +## PrimitiveOf<T> + +Prefer `PrimitiveOf<T>` over custom primitive aliases when a class exposes `toPrimitives()`. + +Good: + +```ts +type TimestampIntervalPrimitives = PrimitiveOf<TimestampInterval>; +``` + +Bad: + +```ts +type TimestampIntervalDto = { + start: number; + end: number; +}; +``` + +Create explicit DTO types only when the external contract deliberately differs from the domain primitive shape. + +Do not force `PrimitiveOf<T>` onto simple `ValueObject` classes that only expose `valueOf()`. For those, either use the primitive at the boundary directly or add a deliberate `toPrimitives()` method if the object has a real serialized contract. + +## Testing rules + +Test behavior through Value Object methods. + +Bad: + +```ts +expect(email.valueOf()).toBe('user@example.com'); +expect(interval.toPrimitives().start).toBe(start.valueOf()); +expect(status.valueOf()).toBe('paid'); +``` + +Good: + +```ts +expect(email.isEqual(new Email('user@example.com'))).toBe(true); +expect(interval.getStart().isEqual(start)).toBe(true); +expect(interval.includes(start)).toBe(true); +expect(status.isPaid()).toBe(true); +``` + +Use primitive assertions only for mappers, serializers, DTO contracts, snapshots, and integration boundaries. + +Enum tests should cover: + +- valid values can be hydrated through the factory, +- invalid values are rejected, +- semantic predicates return the expected result, +- transition methods accept valid transitions, +- transition methods reject invalid transitions, +- serialized boundary output matches the external contract. + +Example: + +```ts +expect(OrderStatus.fromPrimitives('paid').isPaid()).toBe(true); +expect(() => OrderStatus.fromPrimitives('whatever')).toThrow(); +expect(OrderStatus.DRAFT.pay().isPaid()).toBe(true); +expect(() => OrderStatus.CANCELLED.pay()).toThrow(); +``` + +Primitive enum/string assertions are acceptable only when testing boundary mapping: + +```ts +expect(OrderStatusMapper.toPersistence(OrderStatus.PAID)).toBe('paid'); +``` + +## Architecture constraints + +- DDD is mandatory. +- SOLID is mandatory. +- Keep application messages at the application boundary. +- Domain code must not depend on HTTP DTOs, OpenAPI schemas, persistence rows, generated clients, or pub/sub payloads. +- Avoid cross-context domain calls. Coordinate through application services, repositories, events, or API flows. +- Follow existing aggregate patterns before introducing abstractions. +- Do not use `as` casts in application/domain code unless there is no sane alternative. +- `as const` is acceptable for literal tuple declarations that define finite values, but do not use unsafe casts to smuggle invalid primitives into Value Objects. +- Prefer cohesive behavior over procedural services. +- Keep finite-state workflow rules in the aggregate or the relevant `Enum` Value Object, not in application handlers. + +## Naming constraints + +- Classes use UpperCamelCase/PascalCase and singular names: `User`, `UserEmail`, `TimestampInterval`, `OrderStatus`. +- File names use kebab-case and plural names: `users.ts`, `user-emails.ts`, `timestamp-intervals.ts`, `order-statuses.ts`. +- Names must be cohesive. One name, one concept, one reason to change. +- Enum-style Value Objects should name the domain concept, not the implementation: `OrderStatus`, not `OrderStatusEnumValueObject`. +- Avoid vague names like `Data`, `Info`, `Manager`, `Helper`, `Utils`, `Common`, `Base` unless the existing codebase already has a justified convention. + +## Review checklist + +Before handing off code that touches Value Objects, verify: + +- No `.toPrimitives()` is used for comparisons or domain branching. +- No `.valueOf()` or `.toString()` is used for avoidable domain comparison. +- No raw string/number enum value is compared inside domain or application behavior. +- `Enum`/`EnumValueObject`-style classes own allowed values, predicates, and pure transitions. +- Invalid enum/status/type values are rejected by construction or factory hydration. +- Value Objects are built at boundaries and passed into the domain. +- Domain constructors do not receive primitive bags. +- Domain code does not depend on generated DTO enums, OpenAPI enums, persistence enum columns, or UI labels. +- Value Object methods are used for equality, ordering, arithmetic, dates, intervals, ids, enums, and uniqueness. +- Serialization stays in mappers/DTOs/events/persistence. +- Presentation labels and external mappings stay outside the domain. +- Names are cohesive, classes are singular, files are plural kebab-case. +- SOLID and DDD boundaries are preserved. diff --git a/package.json b/package.json index 395a019..a55a869 100644 --- a/package.json +++ b/package.json @@ -242,9 +242,9 @@ "lint": "eslint ./src --ext .ts", "lint:fix": "eslint ./src --ext .ts --fix", "prepack": "yarn build", - "test": "yarn build && c8 node --test --test-concurrency=1 \"tests/**/*.test.mjs\"", + "test": "yarn build && node --test --test-concurrency=1 \"tests/**/*.test.mjs\"", "build:coverage": "rm -rf dist && tsc -p tsconfig.coverage.json", - "test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/KernelDefaultEnvironment.ts\" --exclude \"src/kernel/KernelEnvironment.ts\" --exclude \"src/kernel/KernelEnvironmentForSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchemaInput.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableResolvedValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariableType.ts\" --exclude \"src/kernel/ShutdownCandidate.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test --test-concurrency=1 \"tests/**/*.test.mjs\"", + "test:coverage": "yarn build:coverage && rm -rf .coverage coverage && NODE_V8_COVERAGE=.coverage/tmp node --test --test-concurrency=1 \"tests/**/*.test.mjs\" && c8 report --temp-directory .coverage/tmp --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/KernelDefaultEnvironment.ts\" --exclude \"src/kernel/KernelEnvironment.ts\" --exclude \"src/kernel/KernelEnvironmentForSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchemaInput.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableResolvedValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariableType.ts\" --exclude \"src/kernel/ShutdownCandidate.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [ @@ -255,6 +255,7 @@ ], "license": "MIT", "dependencies": { + "@haskou/flow": "0.1.2", "dotenv": "^17.4.2", "fs-extra": "^11.3.6", "node-dependency-injection": "3.8.0" diff --git a/src/infrastructure/scheduler/Scheduler.ts b/src/infrastructure/scheduler/Scheduler.ts index 7dc60fd..40f9d7e 100644 --- a/src/infrastructure/scheduler/Scheduler.ts +++ b/src/infrastructure/scheduler/Scheduler.ts @@ -1,3 +1,4 @@ +import { Flow, Semaphore } from '@haskou/flow'; import cron from 'node-cron'; import type { CronExpression } from './CronExpression.js'; @@ -8,6 +9,8 @@ import { DefaultSchedulerErrorPolicy } from './DefaultSchedulerErrorPolicy.js'; import { InvalidParseCronExpressionError } from './InvalidParseCronExpressionError.js'; export abstract class Scheduler { + private readonly executionSemaphore = new Semaphore(1); + constructor( private readonly errorPolicy: SchedulerErrorPolicy = new DefaultSchedulerErrorPolicy(), ) {} @@ -33,15 +36,23 @@ export abstract class Scheduler { public abstract getProcessName(): string; public async runOnce(): Promise<void> { + const permit = this.executionSemaphore.tryAcquire(); + + if (permit === null) { + return; + } + try { Kernel.logger?.debug?.(`Scheduler: Executing ${this.getProcessName()}`); - await this.execute(); + await new Flow().task(() => this.execute()).run(); } catch (error: unknown) { if (this.errorPolicy.shouldSkip(error)) { return; } await this.errorPolicy.handle(error, this); + } finally { + permit.release(); } } diff --git a/tests/infrastructure/scheduler/Scheduler.test.mjs b/tests/infrastructure/scheduler/Scheduler.test.mjs index 59672df..49b48e3 100644 --- a/tests/infrastructure/scheduler/Scheduler.test.mjs +++ b/tests/infrastructure/scheduler/Scheduler.test.mjs @@ -51,6 +51,51 @@ test('runs the scheduler once and logs debug information', async () => { ]); }); +test('skips overlapping scheduler executions', async () => { + let activeExecutions = 0; + let executionCount = 0; + let maxActiveExecutions = 0; + let resolveFirstExecution; + let resolveFirstStarted; + const firstExecution = new Promise((resolve) => { + resolveFirstExecution = resolve; + }); + const firstStarted = new Promise((resolve) => { + resolveFirstStarted = resolve; + }); + const scheduler = new (class extends TestScheduler { + async execute() { + executionCount++; + activeExecutions++; + maxActiveExecutions = Math.max(maxActiveExecutions, activeExecutions); + + if (executionCount === 1) { + resolveFirstStarted(); + await firstExecution; + } + + activeExecutions--; + } + })(); + + const firstRun = scheduler.runOnce(); + await firstStarted; + + const secondRun = scheduler.runOnce(); + await Promise.resolve(); + + await secondRun; + assert.equal(executionCount, 1); + assert.equal(maxActiveExecutions, 1); + + resolveFirstExecution(); + await firstRun; + + assert.equal(executionCount, 1); + assert.equal(maxActiveExecutions, 1); + assert.equal(activeExecutions, 0); +}); + test('delegates handled execution errors to the configured policy', async () => { const error = new Error('failed'); const calls = []; diff --git a/yarn.lock b/yarn.lock index 10c6003..d94057b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -552,6 +552,24 @@ eslint-plugin-unused-imports "^4.4.1" globals "^17.7.0" +"@haskou/flow@0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@haskou/flow/-/flow-0.1.2.tgz#7b78d55c20a27d28bcbaa13f0a830f9a21bc7034" + integrity sha512-4a6vChFg7eNoJMeGxPylBow0285k2+gUR1AJxGU/JSi9Va96pxtprBtPmd2vweKUvJGMXL4BerpEafn8fmiMfA== + dependencies: + "@haskou/value-objects" "^2.13.1" + +"@haskou/value-objects@^2.13.1": + version "2.13.1" + resolved "https://registry.yarnpkg.com/@haskou/value-objects/-/value-objects-2.13.1.tgz#7ceaa3fbea5adf36c161b0b243467abe29ebfb45" + integrity sha512-stGgWXh3l0Xoj+xyf2wPmEuezc3pS1BxIMH3fl/6p9jx3Asc4KABwHN0eqmQ0o5mpNgaFmER758I4GFf0leLfQ== + dependencies: + "@noble/ciphers" "^2.2.0" + "@noble/curves" "^2.2.0" + "@noble/hashes" "^2.0.1" + buffer "^6.0.3" + tslib "^2.8.1" + "@humanfs/core@^0.19.2": version "0.19.2" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" @@ -661,6 +679,23 @@ dependencies: sparse-bitfield "^3.0.3" +"@noble/ciphers@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-2.2.0.tgz#84fb45ac9332925d643b80f89ceb0ea2f21dba95" + integrity sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA== + +"@noble/curves@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.2.0.tgz#981be3aadc3bbfbcdb245e78cc97aa6f759246c2" + integrity sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ== + dependencies: + "@noble/hashes" "2.2.0" + +"@noble/hashes@2.2.0", "@noble/hashes@^2.0.1": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" + integrity sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg== + "@nodable/entities@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.2.0.tgz#a1d45a992b022591b1c2b03a77935c939375b642" @@ -1495,6 +1530,11 @@ balanced-match@^4.0.2: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + birpc@^2.3.0: version "2.9.0" resolved "https://registry.yarnpkg.com/birpc/-/birpc-2.9.0.tgz#b59550897e4cd96a223e2a6c1475b572236ed145" @@ -1557,6 +1597,14 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + builtin-modules@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" @@ -2708,6 +2756,11 @@ iconv-lite@~0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -4054,6 +4107,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsscmp@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"