DDD + Hexagonal — a proposal, proven in ai-variables
One pattern we keep hitting
A bounded-context template + hard boundaries
And how AI makes it stick
01
We have no shared architecture
Backend modules share no agreed structure. Business logic, persistence and orchestration blur together, and every feature solves it a different way — so there's no shape to learn, lean on, or enforce.
aiField is the same story —
one object, four unrelated jobs.
02
A template every module follows
infrastructure — adapters
application — use cases
domain
entities · validators · pure rules
Arrows point inward (Dependency Inversion): infra depends on domain, never the reverse.
<bounded-context>/
domain/ Entity, Validator,
errors, ports — pure,
zero infra imports
infrastructure/ Repository (the ONLY
place Prisma lives),
Adapters
application/ Service — orchestrates
use cases
wiring.ts Composition root — the
ONLY place `new` is called
index.ts Curated barrel — the
public contract
Constructor DI — every dependency is a Pick<> of its collaborator, injected via a single Deps object
Ports & adapters — domain defines an interface; infra implements it (Langfuse, Temporal…)
Repository = the only place Prisma lives — domain & application never import @enginy/db
Curated barrel — index.ts exports the contract, not everything
Consumers (api, enrich, web, …) hold ZERO
business logic. They may call ONLY the application layer.
Bounded Context
api/enrich.
A bounded context talks to another by depending on its application service —
injected through Deps. No reaching into a neighbor's domain or repository.
// ai-message/application/aiMessageService.ts
export type AiMessageServiceDeps = {
repository: Pick<AiMessageRepository, 'store' | 'getAiMessageById' | 'getActiveByIds'>
aiMessageToneRepository: Pick<AiMessageToneRepository, 'getAiMessageToneById'>
promptMirrorService: Pick<PromptMirrorService, 'mirrorOnSave'> // ← another BC
aiReferencesManager: Pick<AiReferencesManager, 'getValidatedReferencesForAiMessage'> // ← another BC
aiEntityRichTextService: Pick<AiEntityRichTextService, 'extractNewReferences'> // ← another BC
triggerFillDescription: typeof triggerFillDescription
}
export class AiMessageService {
constructor(private readonly deps: AiMessageServiceDeps) {}
// …calls this.deps.promptMirrorService.mirrorOnSave(...) — never `new`, never a singleton import
}
Real example — descriptionFillService composes four sibling
services (aiResearch, aiMessage, aiSnippet,
messageTemplate) the same way. Cross-context calls are explicit, typed, and
mockable — the dependency graph is visible in the Deps.
A Temporal workflow is an application use case expressed in Temporal. It reaches a bounded context's logic through activities — activities call the wired application services.
// enrich/src/activities/descriptionFillActivities.ts
import { descriptionFillService } from '@enginy/common'
import { sanitizeForDb } from '@enginy/common/utils'
export async function saveEntityDescription({
entityType, entityId, clientId, description,
}: { entityType: AiEntityType; entityId: string; clientId: number; description: string }): Promise<void> {
await descriptionFillService.fillDescription({
entityType, entityId, clientId, description: sanitizeForDb(description),
})
}
The workflow owns the orchestration; the business logic stays in the bounded context. Temporal is a delivery technology at the edge, like HTTP.
Because every dependency is injected, each service ships a testStubs builder —
vi.fn()s shaped exactly like its Deps.
// ai-snippet/application/aiSnippetService.testStubs.ts (trimmed)
export function buildAiSnippetServiceStubs(): { stubs: AiSnippetServiceStubs; service: AiSnippetService } {
const stubs = {
repository: { store: vi.fn(), getAiSnippetById: vi.fn() /* … */ },
promptMirrorService: { mirrorOnSave: vi.fn() },
aiReferencesManager: { getValidatedReferencesForAiSnippet: vi.fn() },
triggerFillDescription: vi.fn(),
} as unknown as AiSnippetServiceStubs
stubs.repository.store.mockResolvedValue(undefined)
const service = new AiSnippetService(stubs as unknown as AiSnippetServiceDeps)
return { stubs, service }
}
// aiSnippetService.test.ts
it('mirrors to Langfuse after storing', async () => {
const { stubs, service } = buildAiSnippetServiceStubs()
await service.create({ /* … */ })
expect(stubs.repository.store).toHaveBeenCalledOnce()
expect(stubs.promptMirrorService.mirrorOnSave).toHaveBeenCalledOnce()
})
vi.mock('@enginy/db') — there's nothing to mock.Deps type — change the deps and the test file fails to compile, not silently at runtime.
We applied this to ai-variables and turned one god object into ~20
small, consistent, independently-owned bounded contexts.
Every one follows the same template → a new engineer (or an AI agent) learns one module and knows all of them.
03
No recommendation — let's weigh both and decide as a team.
// ai-message/wiring.ts
export const aiMessageService =
new AiMessageService({
repository: new AiMessageRepository(),
promptMirrorService, // sibling singleton
aiReferencesManager,
triggerFillDescription,
})
// index.ts re-exports the SINGLETON.
Pros: zero setup for consumers, one obvious instance, fast.
Cons: a hidden global graph; ESM circular-import footguns; hard to run two configs.
api/enrich compose the services themselves — a composition
root at the edge, one per entrypoint.
Pros: explicit dependency graph, per-consumer config, cleaner test isolation.
Cons: boilerplate in every consumer, duplication, consumers must understand wiring.
"Which composition root do we want as the standard? Let's decide as a team."
apply/toProps, Deps + field copies per BC)Rigid, predictable structure is exactly where AI agents excel — and its determinism lets us enforce it mechanically, so agents (and humans) can't drift.
🧹
Fail CI if domain//application/ import @enginy/db, if Prisma escapes infrastructure/, or if a barrel leaks internals. The rules in AGENTS.md become executable.
🔌
Verify services declare deps via Deps/Pick<> and never new a collaborator or import a sibling singleton.
🤖
Wire these checks into the agent harness so AI writes conforming code by construction, not by reminder.
AGENTS.md), so an agent scaffolds a correct module every time. Deterministic
guardrails turn "please follow the architecture" into "you cannot merge code that doesn't."
Not a migration.
AGENTS.md (already living in ai-variables/) is the shared spec — reuse it as the org-wide template."Let's start the next feature this way."