Vue DDD Architecture
A layered, module-per-domain architecture for Vue 3 + TypeScript applications, distilled from a production codebase. It gives a growing app a shape that stays navigable: business logic lives in modules, each module has four layers with one direction of dependency, and each module exposes exactly one public API.
Be honest about what this is
This is DDD-inspired layering, not tactical DDD. There are no aggregates, no value objects, no domain events, no rich entities with behaviour. On the frontend, the domain model is usually already owned by the backend, and reimplementing it in the browser buys complexity without buying correctness.
What this architecture actually delivers is narrower and more useful: business logic is separated from transport and from presentation, every file has one obvious home, modules can be deleted or extracted without archaeology, and the boundaries are machine-checkable by a linter rather than by memory.
Say this plainly if the user calls it "DDD" and expects aggregates. Do not invent a domain model that the app does not need.
Scope
Vue 3 with the Composition API and TypeScript. The layering ideas port to React and other
frameworks, but this skill deliberately does not cover them — the concrete guidance below
(composables as the unit of composition, Pinia stores, unref) is Vue-specific, and vague
framework-neutral advice would produce worse code than no advice.
The shape
src/
├── app/ composition root: router, layouts, providers, config, global styles
├── modules/ business modules — the app's actual subject matter
└── shared/ reusable, business-free building blocks (portable to another project)
A module:
modules/<module>/
├── domain/ types + pure factories. No side effects, no imports of runtime code.
├── application/ use cases. services/ + the use<Module>() facade = the public API.
├── infrastructure/ models/ (talks to the network) + stores/ (holds state)
├── ui/ views/ and components/, each component owning its composables/ and styles/
├── utils/ mappers and helpers specific to this module's subject matter
├── routes.ts this module's route declarations (entity/screen modules)
└── index.ts the public API — the only thing other modules may import
Read references/layers.md for what belongs in each layer, with examples of the mistakes
that keep recurring.
The one rule that makes this worth doing
Dependencies point one way, and modules talk to each other only through their front door.
app ──▶ modules ──▶ shared
sharedknows nothing aboutappormodules. It should survive being copy-pasted into an unrelated project.modulesnever import fromapp.- A module imports another module only as
@/modules/<other>— never@/modules/other/infrastructure/stores. Deep imports are what turn a modular app back into a ball of mud, one "just this once" at a time.
Inside a module:
ui ──▶ application ──▶ infrastructure ──▶ domain
ui never imports infrastructure directly. When a component needs data, it needs a use
case, and the use case is what should be edited when the requirement changes.
These rules are enforced by ESLint, not by good intentions — see
references/eslint-boundaries.md. Set that up early; a boundary that only exists in prose
degrades within weeks.
Deciding where code goes
Ask, in order:
- Does it talk to the outside world or hold state? →
infrastructure(HTTP endpoints inmodels/, reactive state instores/). - Does it describe something the application does? →
application/services/. "Fetch the incomes and put them in the store, tracking loading and errors" is a use case, even though it feels plumbing-ish. - Is it a type, or a factory for an initial value? →
domain/. - Is it a pure transformation of this module's data? →
utils/(mappers, groupers, formatters tied to the subject matter). - Is it rendering, or logic that exists only to serve one component? →
ui/. - Is it none of the above and has no business meaning at all? →
shared/.
The test that resolves most arguments: if the backend changed shape, which files would you edit? Those are infrastructure. If the product requirement changed, which files would you edit? Those are application.
What counts as a module
Not everything deserves a module, and not every module needs every layer. Creating empty layers "for symmetry" is the most common way this architecture accumulates noise.
| Kind | Recognise it by | Required layers |
|---|---|---|
| Entity module | owns an API resource (incomes, wallets, categories) |
all four |
| Screen module | a page composing other modules' data (dashboard, welcome) |
application + ui; no models, usually no store |
| Domain-service module | pure calculation over other modules' data (calculations, pricing) |
domain + application only |
| Cross-cutting state | needed by nearly everything, not a subject in itself (settings, theme, session) |
see below |
The last row is the one that causes arguments, so decide it with a single question: who consumes this?
- Only
app— route guards, layouts, the HTTP transport (a session token, the active locale) → put it inapp/. Modules never importapp, so nothing else can reach it, which is correct: nothing else should. - Business modules consume it too (user settings, selected currency, feature flags) → keep
it a module with a deliberately wide facade, and consume it as
@/modules/settings.
What is never right is a module that everything deep-imports. If you find
@/modules/settings/infrastructure/stores in twenty files, the fix is not to ban the
import — it is that the facade was never written.
Workflows
Pick the one that matches the request and read that file before starting.
| Situation | Read |
|---|---|
| Add a new module, or a feature inside an existing one | references/new-module.md |
| Set up a new Vue project with this architecture | references/new-project.md |
| Restructure an existing pages/components codebase | references/migration.md |
| Check that code follows the rules (do this before reporting done) | references/audit-checklist.md |
| Wire up the boundary linter | references/eslint-boundaries.md |
| Understand a layer in depth | references/layers.md |
A complete, real reference module lives in examples/incomes/ — every layer, CRUD with
pagination, a list component, a form. When writing new code, read it rather than
reconstructing the pattern from the prose above; the prose omits the small things
(barrel files, unref placement, error plumbing) that are exactly where generated code
tends to go wrong.
Adapt to the project, don't overwrite it
Before generating anything, read package.json and look at two or three existing modules
or components.
- State: Pinia is the default this architecture was built against, but the layer
contract is what matters — a store exposes state plus setters, and only
application/servicesmutate it. If the project uses something else, keep the contract and change the implementation. - HTTP:
shared/infrastructure/apiowns the transport, behind a four-methodApiClientinterface. The base services depend on that interface, not on any HTTP library, so a project already using axios, ky, a generated SDK or@vueuse/corekeeps its client and writes one small adapter — seeassets/adapters/. Reaching for hand-written services instead is almost always the wrong trade: it re-implements loading, error and pagination behaviour per module, which is exactly the drift the primitives exist to prevent. - UI kit, styling, i18n: not this skill's business. Match whatever the project already
does — Vant or shadcn-vue, CSS modules or Tailwind, i18n or plain strings. The only UI
rule that is architectural is that components reach data through
application, and that non-trivial component logic lives in a composable next to the component instead of swelling the<script setup>block. - OpenAPI: if the backend publishes a schema, generating DTO types is the cheapest possible domain layer. If it doesn't, hand-write the types — the structure is unchanged.
When the project doesn't fit, say so out loud
Sometimes the project genuinely resists part of this architecture — an HTTP client that
does not map onto ApiClient, a state library without the store contract, a module
boundary the existing code contradicts. Deviating is often the right answer; deviating
silently never is, because the person reading the diff cannot tell a deliberate
adaptation from a misunderstanding.
Stop and put the choice to them: name what does not fit, the two or three ways forward, and which you would pick. A brief question costs a minute. Discovering the divergence later, in a summary or during review, costs the trust that makes the rest of the guidance worth following.
Scaffolding
scripts/scaffold-module.mjs creates the skeleton — every directory, every barrel file,
the index.ts, and minimal valid contents:
node scripts/scaffold-module.mjs --name incomes --kind entity --root src
--kind is one of entity, screen, domain-service and controls which layers appear.
Run it first, then fill in the real content. It exists because the file that gets forgotten
is always index.ts, and a module without its public API silently invites deep imports
from everywhere else.
Before you say it's done
Run the project's own checks — typecheck, lint, build — and read
references/audit-checklist.md. The boundary rules are easy to break accidentally while
concentrating on behaviour, and the linter catches most of it, but not the parts about
side effects in domain/ or a service that has quietly grown a second responsibility.