A Backend Architecture
for Enginy

DDD + Hexagonal — a proposal, proven in ai-variables

Enginy graphic

Agenda

1

The problem

One pattern we keep hitting

2

The proposal

A bounded-context template + hard boundaries

3

Wiring, trade-offs & adoption

And how AI makes it stick

01

The problem

We have no shared architecture

The real problem: we have no 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.

What "no architecture" looks like

  • business rules tangled directly with Prisma
  • no boundaries — modules reach into each other's internals
  • every module invents its own structure
  • dependencies wired implicitly, by convention or by name

Why it hurts

  • Violates Open/Closed — to add a feature you edit everything; you never just extend
  • Hard to test — logic tangled with Prisma; no seams to mock
  • Huge blast radius — one change breaks unrelated use cases
  • Unclear ownership — no team owns the messy middle
  • Fragile coupling — implicit links break silently on a rename
Backwards from SOLID's Open/Closed. It should be open to extension, closed to modification — ours is the opposite. Add one field to a lead and you must already know to edit the CSV exporter, the CRM sync, the AI layer, and more: nothing is extended, everything is edited. aiField is the same story — one object, four unrelated jobs.

02

The proposal

A template every module follows

Hexagonal, in 60 seconds

infrastructure — adapters

application — use cases

domain

entities · validators · pure rules

  • domain — pure rules; knows nothing about the outside world
  • application — orchestrates domain + dependencies
  • infrastructure — Prisma, Temporal, HTTP; implements ports the domain defines

Arrows point inward (Dependency Inversion): infra depends on domain, never the reverse.

Takeaway: dependencies point inward. The domain is the stable core; technology is a detail at the edge.

One template, every module

<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

The rules that make it hold

1

Constructor DI — every dependency is a Pick<> of its collaborator, injected via a single Deps object

2

Ports & adapters — domain defines an interface; infra implements it (Langfuse, Temporal…)

3

Repository = the only place Prisma lives — domain & application never import @enginy/db

4

Curated barrelindex.ts exports the contract, not everything

All the logic lives in the bounded context

Consumers (api, enrich, web, …) hold ZERO business logic. They may call ONLY the application layer.

Consumers
api / enrich / web
only the app layer

Bounded Context

application/  ←  the only exposed face
domain/
(internal)
infrastructure/
(internal)
  • The barrel exposes application services (+ domain types). It does not expose repositories or let consumers reach in.
  • Consumers become thin orchestration: validate request → call a service → return. No Prisma, no domain rules in api/enrich.

How two contexts talk

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.

Temporal is just another use case

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.

Workflow
(orchestration)
Activity
application service
aiMessageService / computAItor
// 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.

Tests with no database, no mocks of Prisma

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()
})
  • No vi.mock('@enginy/db') — there's nothing to mock.
  • The stub surface is locked to the Deps type — change the deps and the test file fails to compile, not silently at runtime.

Proof: this runs in production

We applied this to ai-variables and turned one god object into ~20 small, consistent, independently-owned bounded contexts.

shared (kernel)
ai-message
ai-research
ai-snippet
message-template
ai-message-result
ai-research-result
ai-snippet-result
message-template-result
ai-message-tone
ai-references
ai-entity-rich-text
prompt-library
description-fill
computAItor
preview
folder
custom-prompt-field
custom-prompt-field-value
ai-api

Every one follows the same template → a new engineer (or an AI agent) learns one module and knows all of them.

03

Wiring, trade-offs
& adoption

Where do we wire it all together?

No recommendation — let's weigh both and decide as a team.

A) Module-level singletons (today)

// 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.

B) Wire at each consumer

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."

Is the ceremony worth it?

Wins

Open to extension, closed to modification. Today you must understand the whole system to not break it. With this, a new constraint on an AI entity goes in one place — you don't hunt every entry point that creates an AI variable.
  • All business logic out of consumers → consumers stay thin and swappable
  • Tests with no DB and no Prisma mocks
  • Clear ownership & boundaries → safe parallel work across teams

Costs

  • We must teach the team the pattern up front
  • We must block PRs that break the layers — it only works if enforced
  • Slower at the beginning — more files, more ceremony before the payoff
  • Boilerplate (entity apply/toProps, Deps + field copies per BC)
  • ESM circular-import footguns (the wiring splits from the previous slide)

This architecture is what AI is best at

Rigid, predictable structure is exactly where AI agents excel — and its determinism lets us enforce it mechanically, so agents (and humans) can't drift.

🧹

A layer linter

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.

🔌

A DI checker

Verify services declare deps via Deps/Pick<> and never new a collaborator or import a sibling singleton.

🤖

Harness engineering

Wire these checks into the agent harness so AI writes conforming code by construction, not by reminder.

A repeatable template: "add a new bounded context" is a checklist (already in 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."

How we adopt it

Not a migration.

  • Use it for new features going forward — every new backend feature starts as a bounded context. No big rewrite of existing code.
  • AGENTS.md (already living in ai-variables/) is the shared spec — reuse it as the org-wide template.
  • Add the guardrails (linter / DI checker / harness) so it stays honest.
  • Expect to be slower at first, faster and safer as it spreads.

"Let's start the next feature this way."