Domain-Driven Design (DDD) Architecture
Learn how to structure complex Nuxt applications using Domain-Driven Design principles for maintainable, scalable business applications.
Why DDD for Nuxt Applications
- ✅ Clear separation of business logic and technical concerns
- ✅ Better scalability for large teams and applications
- ✅ Improved code maintainability and testability
- ✅ Align code structure with business domains
Understanding Bounded Contexts
A bounded context defines clear boundaries around a specific domain model. In Nuxt, each bounded context can be a separate feature module.
Project Structure
nuxt-app/
├── modules/
│ ├── users/ # User Management Domain
│ │ ├── composables/
│ │ ├── components/
│ │ ├── server/
│ │ ├── types/
│ │ └── module.ts
│ ├── orders/ # Order Management Domain
│ │ ├── composables/
│ │ ├── components/
│ │ ├── server/
│ │ ├── types/
│ │ └── module.ts
│ └── billing/ # Billing Domain
│ ├── composables/
│ ├── components/
│ ├── server/
│ ├── types/
│ └── module.ts
└── nuxt.config.ts
Creating a Feature Module
User Domain Module
// modules/users/module.ts
import { defineNuxtModule, createResolver } from '@nuxt/kit'
export default defineNuxtModule({
meta: {
name: 'users',
configKey: 'users'
},
setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
// Add auto-imports for composables
nuxt.hook('imports:dirs', (dirs) => {
dirs.push(resolver.resolve('./composables'))
})
// Add components
nuxt.hook('components:dirs', (dirs) => {
dirs.push({
path: resolver.resolve('./components'),
prefix: 'User'
})
})
// Add server routes
nuxt.hook('nitro:config', (nitroConfig) => {
nitroConfig.serverHandlers = nitroConfig.serverHandlers || []
nitroConfig.serverHandlers.push({
route: '/api/users/**',
handler: resolver.resolve('./server/api/users/index.ts')
})
})
}
})
Domain Types
// modules/users/types/index.ts
export interface User {
id: string
email: string
profile: UserProfile
role: UserRole
}
export interface UserProfile {
firstName: string
lastName: string
avatar?: string
}
export enum UserRole {
Admin = 'admin',
User = 'user',
Guest = 'guest'
}
export interface CreateUserCommand {
email: string
password: string
profile: UserProfile
}
export interface UpdateUserCommand {
id: string
profile: Partial<UserProfile>
}
Domain Service
// modules/users/composables/useUserService.ts
import type { User, CreateUserCommand, UpdateUserCommand } from '../types'
export function useUserService() {
const users = ref<User[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
async function createUser(command: CreateUserCommand): Promise<User> {
loading.value = true
error.value = null
try {
const user = await $fetch<User>('/api/users', {
method: 'POST',
body: command
})
users.value.push(user)
return user
} catch (e) {
error.value = e as Error
throw e
} finally {
loading.value = false
}
}
async function updateUser(command: UpdateUserCommand): Promise<User> {
loading.value = true
error.value = null
try {
const user = await $fetch<User>(`/api/users/${command.id}`, {
method: 'PATCH',
body: command
})
const index = users.value.findIndex(u => u.id === command.id)
if (index !== -1) {
users.value[index] = user
}
return user
} catch (e) {
error.value = e as Error
throw e
} finally {
loading.value = false
}
}
async function fetchUsers(): Promise<User[]> {
loading.value = true
error.value = null
try {
users.value = await $fetch<User[]>('/api/users')
return users.value
} catch (e) {
error.value = e as Error
throw e
} finally {
loading.value = false
}
}
return {
users: readonly(users),
loading: readonly(loading),
error: readonly(error),
createUser,
updateUser,
fetchUsers
}
}
Server-side Repository Pattern
// modules/users/server/repositories/UserRepository.ts
import type { User, CreateUserCommand } from '../../types'
export class UserRepository {
private db: any // Your database client
constructor(db: any) {
this.db = db
}
async findById(id: string): Promise<User | null> {
return await this.db.users.findUnique({
where: { id }
})
}
async findByEmail(email: string): Promise<User | null> {
return await this.db.users.findUnique({
where: { email }
})
}
async create(command: CreateUserCommand): Promise<User> {
return await this.db.users.create({
data: {
email: command.email,
password: await hashPassword(command.password),
profile: {
create: command.profile
}
},
include: {
profile: true
}
})
}
async update(id: string, data: Partial<User>): Promise<User> {
return await this.db.users.update({
where: { id },
data,
include: {
profile: true
}
})
}
async delete(id: string): Promise<void> {
await this.db.users.delete({
where: { id }
})
}
}
function hashPassword(password: string): Promise<string> {
// Implementation
return Promise.resolve(password)
}
Cross-Domain Communication
Domain Events
// modules/shared/events/DomainEvent.ts
export interface DomainEvent {
type: string
timestamp: Date
payload: any
}
// modules/users/events/UserCreatedEvent.ts
import type { DomainEvent } from '~/modules/shared/events/DomainEvent'
import type { User } from '../types'
export interface UserCreatedEvent extends DomainEvent {
type: 'user.created'
payload: {
user: User
}
}
Event Bus
// modules/shared/composables/useEventBus.ts
import mitt from 'mitt'
import type { DomainEvent } from '../events/DomainEvent'
const emitter = mitt<Record<string, DomainEvent>>()
export function useEventBus() {
function publish(event: DomainEvent) {
emitter.emit(event.type, event)
}
function subscribe<T extends DomainEvent>(
eventType: string,
handler: (event: T) => void
) {
emitter.on(eventType, handler as any)
return () => emitter.off(eventType, handler as any)
}
return {
publish,
subscribe
}
}
Using Events
// modules/billing/composables/useBillingService.ts
export function useBillingService() {
const { subscribe } = useEventBus()
// Listen to user creation to create billing account
onMounted(() => {
const unsubscribe = subscribe('user.created', async (event) => {
await createBillingAccount(event.payload.user.id)
})
onUnmounted(unsubscribe)
})
async function createBillingAccount(userId: string) {
await $fetch('/api/billing/accounts', {
method: 'POST',
body: { userId }
})
}
return {
createBillingAccount
}
}
Register Modules in Nuxt Config
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'~/modules/users/module',
'~/modules/orders/module',
'~/modules/billing/module'
]
})
Best Practices
✅ Do's
- ✅ Keep domains independent and loosely coupled
- ✅ Use domain events for cross-domain communication
- ✅ Define clear boundaries and interfaces
- ✅ Use TypeScript interfaces for domain contracts
- ✅ Apply repository pattern for data access
- ✅ Use command objects for write operations
- ✅ Organize by feature, not by technical layer
❌ Don'ts
- ❌ Don't share database models directly between domains
- ❌ Don't create circular dependencies between modules
- ❌ Don't leak domain logic to presentation layer
- ❌ Don't bypass domain services with direct API calls
- ❌ Don't mix multiple domains in a single component
Advanced Patterns
Aggregate Root
// modules/orders/domain/Order.ts
export class Order {
constructor(
public readonly id: string,
public customerId: string,
private _items: OrderItem[],
private _status: OrderStatus
) {}
get items(): readonly OrderItem[] {
return this._items
}
get status(): OrderStatus {
return this._status
}
get total(): number {
return this._items.reduce((sum, item) => sum + item.total, 0)
}
addItem(item: OrderItem): void {
if (this._status !== OrderStatus.Draft) {
throw new Error('Cannot add items to non-draft order')
}
this._items.push(item)
}
submit(): void {
if (this._items.length === 0) {
throw new Error('Cannot submit empty order')
}
this._status = OrderStatus.Submitted
}
cancel(): void {
if (this._status === OrderStatus.Shipped) {
throw new Error('Cannot cancel shipped order')
}
this._status = OrderStatus.Cancelled
}
}
Value Objects
// modules/shared/domain/Email.ts
export class Email {
private constructor(private readonly value: string) {}
static create(email: string): Email {
if (!this.isValid(email)) {
throw new Error('Invalid email address')
}
return new Email(email.toLowerCase())
}
static isValid(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
toString(): string {
return this.value
}
equals(other: Email): boolean {
return this.value === other.value
}
}
DDD helps structure complex Nuxt applications by aligning code with business domains, making them more maintainable and scalable.