The Transactional Orchestrator
Between the world and the domain sits a layer that owns how the operation runs without owning what the operation means. @ApplicationService from @frenchexdev/ddd-application-service reifies that layer: load the aggregate, call its method, save the result, dispatch its events, commit the transaction. No domain logic of its own — that lives in the aggregate.
What @ApplicationService Reifies
The pattern's discipline is thin. A good application service method is fifteen lines: load the aggregate, validate the command (often via the aggregate's Result-returning method), commit the result, dispatch events, return success. If the method grows past fifty lines, the suspicion is that domain logic has crept in — pull it back into the aggregate.
The two responsibilities of the application service are orchestration (what to do in what order) and transactional alignment (when to commit, when to dispatch, when to rollback). Domain logic — the rules about what makes a valid subscription, what a cancellation means — belongs to the aggregate. The application service decides when the rules run; the aggregate decides what they are.
The decorator's transactional flag defaults to true because that is the common case: every method on an application service runs inside a single transaction opened by the caller (typically the command handler or the route handler). Setting it to false is the opt-out for read-side services where no transaction is needed.
The Runtime: ddd-application-service
decorator.ts declares the surface. Required name: string, optional transactional: boolean (default true). The decorator stamps __applicationService and __applicationServiceMetadata.
In our invented Subscription context:
import { ApplicationService } from '@frenchexdev/ddd-application-service';
import { ok, err, type Result } from '@frenchexdev/ddd-core';
import type { SubscriptionRepositoryPort } from '../ports/subscription-repository.port.js';
import type { EventBus } from '@frenchexdev/ddd-event-bus';
@ApplicationService({ name: 'SubscriptionService', transactional: true })
export class SubscriptionService {
constructor(
private readonly subscriptions: SubscriptionRepositoryPort,
private readonly bus: EventBus,
) {}
async cancelSubscription(cmd: CancelSubscriptionCommand): Promise<Result<void, CancelFailure>> {
const sub = await this.subscriptions.load(cmd.subscriptionId);
if (!sub) return err({ kind: 'NotFound', id: cmd.subscriptionId });
const cancellation = sub.cancel(cmd.reason);
if (!cancellation.ok) return err(cancellation.error);
await this.subscriptions.save(sub);
await this.bus.publish(cancellation.value);
return ok(undefined);
}
}import { ApplicationService } from '@frenchexdev/ddd-application-service';
import { ok, err, type Result } from '@frenchexdev/ddd-core';
import type { SubscriptionRepositoryPort } from '../ports/subscription-repository.port.js';
import type { EventBus } from '@frenchexdev/ddd-event-bus';
@ApplicationService({ name: 'SubscriptionService', transactional: true })
export class SubscriptionService {
constructor(
private readonly subscriptions: SubscriptionRepositoryPort,
private readonly bus: EventBus,
) {}
async cancelSubscription(cmd: CancelSubscriptionCommand): Promise<Result<void, CancelFailure>> {
const sub = await this.subscriptions.load(cmd.subscriptionId);
if (!sub) return err({ kind: 'NotFound', id: cmd.subscriptionId });
const cancellation = sub.cancel(cmd.reason);
if (!cancellation.ok) return err(cancellation.error);
await this.subscriptions.save(sub);
await this.bus.publish(cancellation.value);
return ok(undefined);
}
}Fourteen lines including the imports. The method does exactly what it says — load, call, save, dispatch — and nothing else. The cancellation's rules (cannot cancel an already-cancelled subscription) live in sub.cancel(reason) on the aggregate. The cancellation's transactional shape lives here.
The Codegen and Analyzer
Spec-first triplet. The analyzer enforces a Service suffix at info severity, single-per-file, and a future cross-pattern rule will check that the service does not import domain logic except through aggregates and repositories — the thin-layer discipline made checkable.
The codegen emits a service registry, useful for the application's bootstrap to wire every service with its dependencies.
Cross-Links
- Invoked by
@CommandHandlerand@QueryHandler— the handlers dispatch through the application service. - Loads from
@Repositoryand saves via the same. - Calls
@AggregateRootmethods; receivesResult<Event, Failure>typed returns. - Dispatches through
@EventBus(in-process) and@Outbox(cross-process). - Owns the transaction boundary the
@AuditTrailand@RBACchecks straddle. - Exposed by one or more
@UseCasedeclarations.
Back to the series index.