Enforced Boundaries

The patterns in the rest of the backend docs are only useful if they're actually followed. ForgeStack doesn't rely on discipline or code review for that. It ships a set of custom ESLint rules (in eslint/rules/) that fail the build when an architectural rule is broken. The architecture is executable.

What gets enforced

All of them run in npm run lint and fail it on a violation. These are the rule ids registered in the root eslint.config.mjs:

RuleWhat it enforces
hexagonal-architecturelayer dependency direction (a no-restricted-imports config, not a named rule)
bounded-context/no-cross-importsno imports between bounded contexts (except shared)
cqrs/handler-collocationhandlers import their command/query/event from the same folder
cqrs/no-direct-decoratorsno raw @CommandHandler / @QueryHandler / @EventsHandler
handler/naminghandler class names end in the suffix that matches their file
entity-domain/no-direct-entity-mutationaggregate state changes only through its own methods
value-object/naming.vo.ts files hold value objects, and value objects live in .vo.ts files
aggregate/must-extend-base and seven more */must-extend-base ruleseach building block extends its base class (table below)

Layer boundaries

hexagonal-architecture (a no-restricted-imports config, not a single named rule) enforces the dependency direction, layer by layer: domain may import only from domain; application may import from domain and application; infrastructure may import from domain and infrastructure; interfaces may import from application and interfaces. A domain file that imports from infrastructure is an error, and so is a controller that reaches past the command and query buses into domain objects. The core stays pure.

Bounded-context isolation

bounded-context/no-cross-imports stops one bounded context from reaching into another's internals. Keyed off the @bc/* path alias: from inside @bc/auth you may import @bc/auth/... and @bc/shared/..., but not @bc/notifications/.... Contexts collaborate through integration events, not imports.

// eslint/rules/no-cross-bc-imports.mjs (essence)
const BC_PATH_REGEX = /bounded-contexts\/([^/]+)\//;
const BC_ALIAS_REGEX = /^@bc\/([^/]+)/;
// flag when importing BC !== current BC (allowing 'shared')

CQRS collocation & naming

  • cqrs/handler-collocation: a *.command-handler.ts must import its command from the same directory (likewise queries; domain-event handlers import the event from domain/.../events). Keeps each use case self-contained.
  • handler/naming: handler classes must end with the right suffix for their file: _CommandHandler in *.command-handler.ts, _QueryHandler in *.query-handler.ts, _DomainEventHandler in *.domain-event-handler.ts, _IntegrationEventHandler in *.integration-event-handler.ts.
  • cqrs/no-direct-decorators: NestJS's @CommandHandler, @QueryHandler and @EventsHandler decorators are forbidden outside the framework's own CQRS code. Extend Base_CommandHandler(Command), Base_QueryHandler(Query) or Base_DomainEventHandler(Event) instead: the base registers the handler and adds the tracing, metrics and logging a raw decorator would silently skip.

Base-class conformance

A family of rules guarantees the building blocks actually extend the right base, so the cross-cutting behaviour (events, tracing, metrics, transactions) is never accidentally missing:

RuleFile suffixMust extend / implement
aggregate/must-extend-base*.aggregate.tsSharedAggregate
aggregate-dto/must-extend-base*.dto.ts under domain/aggregates/SharedAggregateDTO
value-object/naming*.vo.tsthe value-object interface (and VOs must live in *.vo.ts)
command-handler/must-extend-base*.command-handler.tsBase_CommandHandler(Command)
query-handler/must-extend-base*.query-handler.tsBase_QueryHandler(Query)<Response>()
domain-event/must-extend-base*.domain-event.tsBase_DomainEvent
domain-event-handler/must-extend-basedomain-event handlersBase_DomainEventHandler(Event)
integration-event/must-extend-base*.integration-event.tsBase_IntegrationEvent
repository/must-extend-base*.repository.ts (the port interface)Repository<Aggregate, Id>

Aggregate immutability

entity-domain/no-direct-entity-mutation forbids assigning directly to properties of a SharedAggregate instance from outside its own methods. State must change through intention-revealing behaviour (user.canAuthenticate(), outbox.markAsProcessed()) so invariants always run.

Why enforce instead of advise

Three reasons:

  • Boundaries erode silently. A single "just this once" import from domain into infrastructure is how layered architectures rot. A rule turns that into a red build, not a future refactor.
  • It's the best documentation. New contributors (and AI agents) learn the rules by hitting them, with a precise message pointing at the fix.
  • It keeps the promises true. Other docs say "the domain is pure" and "contexts talk only through events". These rules are what make those statements reliably true, not aspirational.

Extending the rules

The rules live in eslint/rules/ as small, standalone .mjs files. When you add a new convention to your fork, add a rule for it. That's how the architecture stays enforceable as it grows.

That completes the backend tour. Continue with the Frontend.