Skip to main content
Advertisement

17.3 Layered Architecture — Repository/Service/Controller Type Boundaries

Layered Architecture Overview

HTTP Request → Controller (input validation)
→ Service (business logic)
→ Repository (data access)
→ Database

Each layer should have clear type boundaries. Types from lower layers should not leak into upper layers.


Domain Type Definitions

// domain/types.ts

// Domain entities — business objects independent of DB
export interface User {
readonly id: string
readonly email: string
readonly name: string
readonly role: UserRole
readonly createdAt: Date
readonly updatedAt: Date
}

export type UserRole = 'admin' | 'user' | 'guest'

// Value object (immutable)
export type Email = string & { readonly _brand: 'Email' }

export function createEmail(value: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error(`Invalid email: ${value}`)
}
return value as Email
}

// DTOs (Data Transfer Objects)
export interface CreateUserDto {
email: string
name: string
password: string
role?: UserRole
}

export interface UpdateUserDto {
name?: string
role?: UserRole
}

// Response types — exclude sensitive fields
export type UserResponse = Omit<User, never> // Expose all fields
export type PublicUserResponse = Pick<User, 'id' | 'name'> // Public info only

Repository Layer

// repositories/user.repository.interface.ts

// Interface — hides DB implementation from business logic
export interface UserRepository {
findById(id: string): Promise<User | null>
findByEmail(email: string): Promise<User | null>
findAll(options?: FindAllOptions): Promise<PaginatedResult<User>>
save(user: Partial<User> & Pick<User, 'id'>): Promise<User>
create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User>
delete(id: string): Promise<void>
}

export interface FindAllOptions {
page?: number
limit?: number
search?: string
role?: UserRole
}

export interface PaginatedResult<T> {
data: T[]
total: number
page: number
limit: number
}
// repositories/prisma-user.repository.ts

// Prisma implementation — conforms to interface
@Injectable()
export class PrismaUserRepository implements UserRepository {
constructor(private readonly prisma: PrismaService) {}

async findById(id: string): Promise<User | null> {
const record = await this.prisma.user.findUnique({ where: { id } })
return record ? this.toDomain(record) : null
}

// DB record → domain object conversion (type boundary!)
private toDomain(record: PrismaUser): User {
return {
id: record.id,
email: record.email,
name: record.name,
role: record.role as UserRole,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
}

Service Layer

// services/user.service.ts

@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository, // Depends on interface
private readonly passwordService: PasswordService,
private readonly eventBus: EventBus,
) {}

async createUser(dto: CreateUserDto): Promise<UserResponse> {
// 1. Business rule validation
const existing = await this.userRepository.findByEmail(dto.email)
if (existing) {
throw new ConflictError('Email is already in use.')
}

// 2. Create value objects (with validation)
const email = createEmail(dto.email)

// 3. Execute business logic
const hashedPassword = await this.passwordService.hash(dto.password)

const user = await this.userRepository.create({
email,
name: dto.name,
password: hashedPassword,
role: dto.role ?? 'user',
})

// 4. Publish domain event
await this.eventBus.publish(new UserCreatedEvent(user))

// 5. Convert to response DTO (excluding password)
return this.toResponse(user)
}

private toResponse(user: User): UserResponse {
const { ...response } = user
return response
}
}

Controller Layer

// controllers/user.controller.ts

@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}

@Post()
@HttpCode(201)
async create(@Body() dto: CreateUserDto): Promise<UserResponse> {
// Controller handles only HTTP concerns
return this.userService.createUser(dto)
}

@Get()
async findAll(@Query() query: FindAllOptions): Promise<PaginatedResult<UserResponse>> {
return this.userService.findAll(query)
}

@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<UserResponse> {
const user = await this.userService.findById(id)
if (!user) throw new NotFoundException()
return user
}
}

Dependency Inversion Principle (DIP)

// Wrong — high-level module directly depends on low-level implementation
class UserService {
private repo = new PrismaUserRepository() // Direct concrete class ❌
}

// Correct — depend on interface
class UserService {
constructor(
private readonly userRepo: UserRepository // Depends on interface ✅
) {}
}

// IoC container can swap implementations
// Testing: MockUserRepository
// Development: PrismaUserRepository
// Legacy: TypeOrmUserRepository

Pro Tips

Automate Layer-to-Layer Type Conversion

// Using class-transformer mapping library
import { plainToInstance, Expose, Exclude } from 'class-transformer'

@Exclude() // Hide all fields by default
export class UserResponseDto {
@Expose() id: string
@Expose() email: string
@Expose() name: string
@Expose() role: string
@Expose() createdAt: Date
// password is auto-excluded (no @Expose())
}

function toUserResponse(user: User): UserResponseDto {
return plainToInstance(UserResponseDto, user, {
excludeExtraneousValues: true,
})
}
Advertisement