Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Construction with Invariants at Birth

The constructor signature of an aggregate cannot express every input that arrives must result in a successful payment authorisation and a valid customer reference. The constructor either accepts the unvalidated inputs and throws (giving the caller a runtime error with no type-level warning) or accepts pre-validated inputs (pushing the validation work onto every caller). @Factory from @frenchexdev/ddd-factory is the third option: a named, typed object whose single responsibility is to validate the inputs, enforce the invariants, and produce the aggregate.


What @Factory Reifies

Evans (Domain-Driven Design, chapter 6) introduces factories for two cases: complex construction where the aggregate has many fields, many invariants, and the constructor's argument list would be unwieldy; and cross-aggregate construction where the factory needs to reach out to a domain service or another aggregate to validate its inputs. Both cases share the same shape — the factory is a typed object, often stateless, whose method returns Result<Aggregate, ConstructionFailure>.

The pattern's discipline is that the factory is the only place an aggregate is built from raw inputs. The constructor still exists — it is what the factory calls once the inputs are validated — but it is private or protected, and the application never invokes it directly. Replacing all the call sites with the factory is the move; afterwards, every aggregate that comes out of the factory is by definition valid.

@Factory({ produces: 'Subscription' }) declares the binding between the factory and its target. The codegen and the analyzer use the binding to validate that the factory's signature matches the produced aggregate's shape, and that every aggregate has at most one factory.


The Runtime: ddd-factory

decorator.ts declares the minimum. FactoryOptions requires produces: string — the class name of the aggregate or value object this factory builds. The decorator stamps __factory and __factoryMetadata onto the factory class.

In our invented Subscription context, a factory that validates a new subscription against a domain service before construction:

import { Factory } from '@frenchexdev/ddd-factory';
import { ok, err, type Result, Guard } from '@frenchexdev/ddd-core';
import { Subscription } from '../aggregates/subscription.js';
import type { CustomerRepositoryPort, BillingPlanRepositoryPort } from '../ports/index.js';

export type StartSubscriptionFailure =
  | { kind: 'CustomerNotFound';     customerId: CustomerId }
  | { kind: 'BillingPlanInactive';  planId:     BillingPlanId }
  | { kind: 'InvalidStartDate';     reason:     string };

@Factory({ produces: 'Subscription' })
export class SubscriptionFactory {
  constructor(
    private readonly customers: CustomerRepositoryPort,
    private readonly plans:     BillingPlanRepositoryPort,
  ) {}

  async start(
    customerId: CustomerId,
    planId:     BillingPlanId,
    startDate:  string,
  ): Promise<Result<Subscription, StartSubscriptionFailure>> {
    Guard.nonEmptyString(startDate, 'startDate');

    const customer = await this.customers.findById(customerId);
    if (!customer) return err({ kind: 'CustomerNotFound', customerId });

    const plan = await this.plans.findById(planId);
    if (!plan || plan.status !== 'active') return err({ kind: 'BillingPlanInactive', planId });

    return ok(Subscription.startNew(customerId, planId, startDate));
  }
}

The factory's start method does three things the constructor cannot. It reaches across aggregates (looking up the customer through a repository). It validates an invariant whose answer depends on external state (the billing plan must be currently active). It returns a typed Result — the caller pattern-matches on the failure kinds rather than catching a thrown exception.

The factory itself is constructor-injected with its dependencies — typically repository ports — making it testable: substitute the ports with in-memory adapters, the factory's behaviour is fully exercised without a database.


The Analyzer: ddd-factory-analyzer

The analyzer for this pattern is hand-written, like Domain Event and Value Object. There is no spec.ts calling defineAnalyzerSpec; instead, codes.ts exports two diagnostic factories in the DDD0NNN namespace.

DDD0150_FACTORY_RETURNS_INVALID is the invariant-enforcement rule at error severity — a factory that returns an instance without validating it has missed the point of the pattern:

export const DDD0150_FACTORY_RETURNS_INVALID = 'DDD0150';

export function factoryReturnsInvalid(className: string, file: string, line: number): Diagnostic {
  return {
    code: DDD0150_FACTORY_RETURNS_INVALID,
    severity: 'error',
    message: `@Factory "${className}" can return an instance that violates its target's invariants. Validate before returning.`,
    file,
    line,
  };
}

DDD0151_FACTORY_PRODUCES_UNKNOWN_TYPE is the cross-reference check at warning severity — the analyzer looks at the decorator's produces field and tries to resolve it against the workspace's known @AggregateRoot and @ValueObject stamps. Warning rather than error because the produced type may legitimately live in a sibling workspace not visible to the current analyzer pass:

factoryProducesUnknownType('SubscriptionFactory', 'Subscription', 'factories/subscription.ts');
// DDD0151 [warning] @Factory "SubscriptionFactory" declares produces: "Subscription"
// but no such @AggregateRoot or @ValueObject is known in this workspace.

The hand-written shape of this triplet's analyzer places it on the same migration list as domain-event-analyzer and value-object-analyzer. A future milestone — tracked via PROP-FACTORY-001 (code namespace migration DDD0150/0151 → DDD-FACTORY-NNN) and PROP-FACTORY-002 (spec-first codegen pass) — will rewrite both analyzer and codegen on top of defineAnalyzerSpec / defineCodegenSpec. The two codes are part of the published contract that must survive that migration, which is why they exist as named constants rather than inline strings.


The Codegen: ddd-factory-codegen

The codegen is also hand-written. generateFactoryContract emits a {{Factory}}Contract interface whose create(input): T | { error } signature documents the produced class's expected construction shape. The leaf factory class implements the contract, the analyzer cross-references produces to verify the target is a known aggregate, and the contract becomes the typed seam between caller and factory.


  • Produces @AggregateRoot or @ValueObject instances.
  • Uses ddd-core primitives — Guard for input checks, Result<T, E> for typed failures.
  • Reaches across aggregates via @Repository ports; cross-aggregate invariants flow through the factory.
  • May call @DomainService for invariants that span domain logic.

Back to the series index.

⬇ Download