Layered Architecture

Domain, Application, and Infrastructure Patterns

Jason Kuruzovich

2026-01-23

Layered Architecture

Separation of Concerns in Modern Applications

Today’s Agenda

Learning Objectives

  1. Understand the four-layer architecture pattern
  2. Implement domain entities with business rules
  3. Write application services that orchestrate use cases
  4. Apply validation and error handling patterns
  5. Write unit tests for business logic
  6. Understand how layers connect through dependency injection

The Problem: Spaghetti Code

Without Architecture

app.post('/tasks', async (req, res) => {
  // Validation mixed with DB logic
  if (!req.body.title) {
    return res.status(400).json({error: 'No title'});
  }
  // Business rules scattered everywhere
  if (req.body.status === 'completed') {
    req.body.completedAt = new Date();
  }
  // Direct database access
  const task = await db.collection('tasks')
    .insertOne(req.body);
  res.json(task);
});
  • Business logic in controllers
  • Hard to test
  • Hard to maintain
  • Hard to reuse

With Layered Architecture

// Controller - just HTTP handling
app.post('/tasks', async (req, res) => {
  const task = await taskService.createTask(req.body);
  res.json(task);
});

// Service - orchestration
async createTask(data) {
  const task = new Task(data);
  task.validate();
  return this.repository.save(task);
}

// Entity - business rules
validate() {
  if (!this.title) throw new ValidationError();
}
  • Clear separation
  • Easy to test
  • Easy to maintain
  • Reusable logic

The Four Layers

Architecture Overview

flowchart TB
    subgraph Presentation["Presentation Layer"]
        Controllers[Controllers]
        Routes[Routes]
        Validators[HTTP Validators]
    end

    subgraph Application["Application Layer"]
        Services[Application Services]
        DTOs[Data Transfer Objects]
    end

    subgraph Domain["Domain Layer"]
        Entities[Entities]
        DomainServices[Domain Services]
        Interfaces[Repository Interfaces]
    end

    subgraph Infrastructure["Infrastructure Layer"]
        Repositories[Repositories]
        Database[Database]
        External[External Services]
    end

    Presentation --> Application
    Application --> Domain
    Infrastructure --> Domain
    Infrastructure --> Database

Layer Responsibilities

Layer Responsibility Dependencies
Presentation HTTP handling, routing, input validation Application
Application Use case orchestration, transaction management Domain
Domain Business rules, entities, core logic None (pure)
Infrastructure Data persistence, external services Domain interfaces

Key Principle: Dependencies flow downward only. The Domain layer has no dependencies on other layers.

Why This Matters

Testability

// Test domain logic without database
const task = new Task({
  title: 'Test',
  priority: 'high'
});

expect(task.isHighPriority()).toBe(true);
  • Unit test business rules in isolation
  • Mock repositories for service tests
  • Fast test execution

Maintainability

Change request: "Add email notifications"

Only modify:
├── infrastructure/
│   └── services/EmailService.js  ← New
└── application/
    └── services/TaskService.js   ← Inject & use

Domain layer: unchanged
Presentation layer: unchanged
  • Changes isolated to specific layers
  • Clear boundaries reduce bugs
  • Easier onboarding for new developers

Domain Layer

The Heart of Your Application

What Belongs in the Domain Layer?

The domain layer contains pure business logic with no external dependencies.

domain/
├── entities/           # Core business objects
│   └── Task.js         # Task with business methods
├── services/           # Domain operations
│   └── TaskDomainService.js
├── interfaces/         # Contracts (ports)
│   └── ITaskRepository.js
└── errors/             # Business exceptions
    └── DomainErrors.js

Rule: If you can explain it to a non-technical stakeholder, it belongs in the domain.

  • “A high priority task needs immediate attention” ✓
  • “We use MongoDB with Mongoose” ✗

Entity: The Task Class

class Task {
  constructor(data = {}) {
    this.id = data.id || null;
    this.title = data.title || '';
    this.description = data.description || '';
    this.status = data.status || 'pending';
    this.priority = data.priority || 'medium';
    this.createdAt = data.createdAt ? new Date(data.createdAt) : new Date();
    this.updatedAt = data.updatedAt ? new Date(data.updatedAt) : new Date();
  }

  // Business methods go here...
}

An entity is more than a data container - it encapsulates business rules.

Entity Methods: isHighPriority()

/**
 * Check if the task needs immediate attention
 * Business rule: A task is high priority if:
 * - It has priority set to 'high'
 * - It's not completed
 */
isHighPriority() {
  if (this.status === 'completed') return false;
  return this.priority === 'high';
}

Test it:

const task = new Task({
  title: 'Review PR',
  priority: 'high',
  status: 'pending'
});

expect(task.isHighPriority()).toBe(true);

Entity Methods: complete()

/**
 * Mark the task as completed
 * Updates status and timestamp
 */
complete() {
  this.status = 'completed';
  this.updatedAt = new Date();
}

Why a method instead of direct assignment?

// Bad: Business logic scattered
task.status = 'completed';
task.updatedAt = new Date();
// Did we forget anything? Who knows...

// Good: Encapsulated behavior
task.complete();
// All related changes happen together

Entity Methods: validate()

/**
 * Validate the task against business rules
 * @returns {string[]} Array of error messages
 */
validate() {
  const errors = [];

  // Title is required and must be 3-100 characters
  if (!this.title || this.title.trim().length === 0) {
    errors.push('Title is required');
  } else if (this.title.length < 3 || this.title.length > 100) {
    errors.push('Title must be between 3 and 100 characters');
  }

  // Status must be valid
  const validStatuses = ['pending', 'in-progress', 'completed'];
  if (!validStatuses.includes(this.status)) {
    errors.push(`Status must be one of: ${validStatuses.join(', ')}`);
  }

  // Priority must be valid
  const validPriorities = ['low', 'medium', 'high'];
  if (!validPriorities.includes(this.priority)) {
    errors.push(`Priority must be one of: ${validPriorities.join(', ')}`);
  }

  return errors;
}

Domain Service: Business Operations

class TaskDomainService {
  /**
   * Check if a status transition is valid
   * Business rules:
   * - pending → in-progress (start work)
   * - in-progress → completed (finish work)
   * - any → pending (reset/reopen)
   */
  canTransitionStatus(currentStatus, newStatus) {
    // Same status is always allowed
    if (currentStatus === newStatus) return true;

    // Can always reset to pending
    if (newStatus === 'pending') return true;

    // Define valid transitions
    const transitions = {
      'pending': ['in-progress'],
      'in-progress': ['completed']
    };

    return transitions[currentStatus]?.includes(newStatus) || false;
  }
}

Domain Service: Priority Scoring

/**
 * Calculate a priority score (1-10) for sorting/display
 * Higher score = more urgent
 */
calculatePriorityScore(task) {
  // Base score from priority level
  const priorityBase = {
    'low': 2,
    'medium': 5,
    'high': 8
  };

  let score = priorityBase[task.priority] || 5;

  // Adjust for status
  if (task.status === 'in-progress') {
    score += 1;  // In-progress tasks get slight boost
  }

  return Math.min(10, Math.max(1, score));
}

Repository Interface (Port)

/**
 * Repository interface - defines what we need from persistence
 * The DOMAIN defines this, INFRASTRUCTURE implements it
 */
class ITaskRepository {
  async findAll(filters) { throw new Error('Not implemented'); }
  async findById(id) { throw new Error('Not implemented'); }
  async save(task) { throw new Error('Not implemented'); }
  async update(id, task) { throw new Error('Not implemented'); }
  async delete(id) { throw new Error('Not implemented'); }
}

Dependency Inversion Principle:

  • Domain defines the interface (what it needs)
  • Infrastructure provides the implementation (how it’s done)
  • Domain doesn’t know about MongoDB, SQL, or any specific technology

Application Layer

Orchestrating Use Cases

What Belongs in the Application Layer?

The application layer coordinates between presentation and domain.

application/
├── services/
│   └── TaskService.js    # Use case orchestration
└── errors/
    └── ApplicationErrors.js

Responsibilities:

  • Orchestrate multiple domain operations
  • Handle transactions
  • Convert between DTOs and entities
  • Apply application-level validation
  • Throw application-specific errors

Application Service Pattern

class TaskService {
  constructor(taskRepository, domainService) {
    // Dependencies injected - easy to test with mocks
    this.taskRepository = taskRepository;
    this.domainService = domainService;
  }

  // Use case methods...
}

Key Insight: The service doesn’t create its dependencies - they’re injected. This enables:

  • Unit testing with mock repositories
  • Swapping implementations (MongoDB → PostgreSQL)
  • Clear dependency graph

Use Case: Create Task

async createTask(taskData) {
  // 1. Create domain entity
  const task = new Task({
    ...taskData,
    status: 'pending',
    createdAt: new Date(),
    updatedAt: new Date()
  });

  // 2. Validate using domain rules
  const errors = task.validate();
  if (errors.length > 0) {
    throw new ValidationError('Invalid task data', errors);
  }

  // 3. Persist via repository
  const savedTask = await this.taskRepository.save(task);

  // 4. Return the result
  return savedTask;
}

Use Case: Update Task Status

async updateTaskStatus(id, newStatus) {
  // 1. Get existing task
  const task = await this.taskRepository.findById(id);
  if (!task) {
    throw new NotFoundError(`Task with id ${id} not found`);
  }

  // 2. Validate transition using domain service
  if (!this.domainService.canTransitionStatus(task.status, newStatus)) {
    throw new ValidationError(
      `Cannot transition from '${task.status}' to '${newStatus}'`
    );
  }

  // 3. Update and persist
  task.status = newStatus;
  task.updatedAt = new Date();

  return await this.taskRepository.update(id, task);
}

Use Case: Get Task Statistics

async getTaskStatistics() {
  // 1. Get all tasks
  const tasks = await this.taskRepository.findAll();

  // 2. Calculate statistics using domain logic
  const overdueTasks = this.domainService.getOverdueTasks(tasks);

  // 3. Aggregate results
  return {
    total: tasks.length,
    byStatus: {
      pending: tasks.filter(t => t.status === 'pending').length,
      inProgress: tasks.filter(t => t.status === 'in-progress').length,
      completed: tasks.filter(t => t.status === 'completed').length
    },
    overdue: overdueTasks.length,
    averagePriorityScore: this.calculateAveragePriority(tasks)
  };
}

Error Handling Strategy

// Domain errors - business rule violations
class ValidationError extends Error {
  constructor(message, errors = []) {
    super(message);
    this.name = 'ValidationError';
    this.errors = errors;
    this.statusCode = 400;
  }
}

// Application errors - resource issues
class NotFoundError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NotFoundError';
    this.statusCode = 404;
  }
}

Error flow: Domain/Application → Controller → HTTP Response

// In error middleware
if (error instanceof NotFoundError) {
  return res.status(404).json({ error: error.message });
}

Infrastructure Layer

Implementing Persistence

Repository Implementation

class MongoTaskRepository {
  async findById(id) {
    const doc = await TaskModel.findById(id);
    if (!doc) return null;
    return this.toEntity(doc);  // Convert to domain entity
  }

  async save(task) {
    const doc = new TaskModel(this.toDocument(task));
    const saved = await doc.save();
    return this.toEntity(saved);
  }

  // Convert between MongoDB document and domain entity
  toEntity(doc) {
    return new Task({
      id: doc._id.toString(),
      title: doc.title,
      status: doc.status,
      // ... other fields
    });
  }

  toDocument(task) {
    return {
      title: task.title,
      status: task.status,
      // ... other fields
    };
  }
}

Why Separate Entity and Document?

Domain Entity

class Task {
  id: string
  title: string
  status: 'pending' | 'in-progress' | 'completed'
  priority: 'low' | 'medium' | 'high'

  isHighPriority() { ... }
  complete() { ... }
  validate() { ... }
}
  • Has behavior (methods)
  • Uses domain language
  • No database concerns

MongoDB Document

const TaskSchema = {
  _id: ObjectId,
  title: String,
  status: String,
  priority: String,
  created_at: Date,
  __v: Number
}
  • Pure data
  • Database conventions (_id, __v)
  • Mongoose-specific concerns

The repository translates between these two representations.

Dependency Injection

Wiring It All Together

The Composition Root

// index.js - Application entry point
import express from 'express';
import { connectDB } from './infrastructure/database/connection.js';
import { MongoTaskRepository } from './infrastructure/repositories/MongoTaskRepository.js';
import { TaskDomainService } from './domain/services/TaskDomainService.js';
import { TaskService } from './application/services/TaskService.js';
import { TaskController } from './presentation/controllers/TaskController.js';
import { createTaskRoutes } from './presentation/routes/taskRoutes.js';

async function bootstrap() {
  await connectDB();

  // Create instances (dependency injection)
  const taskRepository = new MongoTaskRepository();
  const domainService = new TaskDomainService();
  const taskService = new TaskService(taskRepository, domainService);
  const taskController = new TaskController(taskService);

  // Wire up routes
  const app = express();
  app.use('/api/tasks', createTaskRoutes(taskController));

  app.listen(3000);
}

bootstrap();

Dependency Flow Visualization

flowchart LR
    subgraph Composition["Composition Root (index.js)"]
        direction TB
        Boot[Bootstrap]
    end

    subgraph Infra["Infrastructure"]
        Repo[MongoTaskRepository]
    end

    subgraph Domain["Domain"]
        DS[TaskDomainService]
        Entity[Task Entity]
    end

    subgraph App["Application"]
        AS[TaskService]
    end

    subgraph Pres["Presentation"]
        Ctrl[TaskController]
        Routes[Express Routes]
    end

    Boot --> Repo
    Boot --> DS
    Boot --> AS
    Boot --> Ctrl
    Boot --> Routes

    AS --> Repo
    AS --> DS
    AS --> Entity
    Ctrl --> AS
    Routes --> Ctrl

Benefits of Dependency Injection

For Testing

// Unit test with mock repository
const mockRepo = {
  findById: jest.fn().mockResolvedValue(
    new Task({ id: '1', title: 'Test' })
  )
};

const service = new TaskService(
  mockRepo,
  new TaskDomainService()
);

// Test without real database!
const task = await service.getTaskById('1');
expect(mockRepo.findById).toHaveBeenCalledWith('1');

For Flexibility

// Swap implementations easily
const taskRepository = process.env.DB_TYPE === 'postgres'
  ? new PostgresTaskRepository()
  : new MongoTaskRepository();

// Same service, different storage
const taskService = new TaskService(
  taskRepository,
  domainService
);

Testing Strategy

Testing Pyramid

                    ┌───────────┐
                   /   E2E      \           Few, slow, expensive
                  / (Playwright) \
                 /─────────────────\
                /   Integration     \       Some, medium speed
               /   (Supertest +     \
              /      MongoDB)        \
             /─────────────────────────\
            /       Unit Tests          \   Many, fast, cheap
           /    (Jest + Mocks)           \
          /───────────────────────────────\

Layered architecture makes each level easier to test.

Unit Testing Domain Logic

describe('Task Entity', () => {
  describe('isHighPriority', () => {
    it('returns true when priority is high and not completed', () => {
      const task = new Task({
        title: 'Test Task',
        priority: 'high',
        status: 'pending'
      });

      expect(task.isHighPriority()).toBe(true);
    });

    it('returns false for completed tasks', () => {
      const task = new Task({
        title: 'Test Task',
        priority: 'high',
        status: 'completed'
      });

      expect(task.isHighPriority()).toBe(false);
    });

    it('returns false when priority is not high', () => {
      const task = new Task({
        title: 'Test Task',
        priority: 'medium',
        status: 'pending'
      });

      expect(task.isHighPriority()).toBe(false);
    });
  });
});

Unit Testing Application Services

describe('TaskService', () => {
  let taskService;
  let mockRepository;
  let domainService;

  beforeEach(() => {
    mockRepository = {
      findById: jest.fn(),
      save: jest.fn(),
      update: jest.fn()
    };
    domainService = new TaskDomainService();
    taskService = new TaskService(mockRepository, domainService);
  });

  describe('updateTaskStatus', () => {
    it('throws NotFoundError when task does not exist', async () => {
      mockRepository.findById.mockResolvedValue(null);

      await expect(taskService.updateTaskStatus('123', 'completed'))
        .rejects.toThrow(NotFoundError);
    });

    it('throws ValidationError for invalid transition', async () => {
      mockRepository.findById.mockResolvedValue(
        new Task({ id: '123', status: 'pending' })
      );

      await expect(taskService.updateTaskStatus('123', 'completed'))
        .rejects.toThrow(ValidationError);
    });
  });
});

Integration Testing

describe('Task API', () => {
  beforeAll(async () => {
    await connectDB(process.env.TEST_MONGODB_URI);
  });

  afterEach(async () => {
    await TaskModel.deleteMany({});
  });

  describe('POST /api/tasks', () => {
    it('creates a new task', async () => {
      const response = await request(app)
        .post('/api/tasks')
        .send({
          title: 'Integration Test Task',
          priority: 'high'
        });

      expect(response.status).toBe(201);
      expect(response.body.success).toBe(true);
      expect(response.body.data.title).toBe('Integration Test Task');
    });

    it('returns 400 for invalid data', async () => {
      const response = await request(app)
        .post('/api/tasks')
        .send({ title: 'AB' });  // Too short

      expect(response.status).toBe(400);
      expect(response.body.success).toBe(false);
    });
  });
});

Lab 2 Walkthrough

Project Structure

lab2-server/
├── src/
│   ├── presentation/           # PROVIDED - complete
│   │   ├── controllers/        # TaskController
│   │   ├── routes/             # Express routes
│   │   └── validators/         # Input validation
│   ├── application/            # TODO - implement
│   │   └── services/           # TaskService
│   ├── domain/                 # TODO - implement
│   │   ├── entities/           # Task entity
│   │   ├── interfaces/         # Repository interface
│   │   └── services/           # TaskDomainService
│   └── infrastructure/         # PROVIDED - complete
│       ├── database/           # MongoDB connection
│       └── repositories/       # MongoTaskRepository
└── tests/
    ├── unit/                   # Unit tests
    └── integration/            # API tests

Your Tasks

File Methods to Implement
domain/entities/Task.js isOverdue(), complete(), validate()
domain/services/TaskDomainService.js canTransitionStatus(), calculatePriorityScore(), getOverdueTasks()
application/services/TaskService.js All 7 service methods

Tips:

  1. Start with the domain layer (Task entity)
  2. Run tests frequently: npm test
  3. Read the existing code to understand patterns
  4. Look for // TODO: comments with hints

Quick Start Commands

# Install all dependencies
npm run install:all

# Copy environment file
cp lab2-server/.env.example lab2-server/.env

# Start with Docker (recommended)
npm run docker:up

# OR run locally
docker compose up mongo -d
npm run dev:server
npm run dev:client  # In another terminal

# Run tests
npm test

# Verify setup
curl http://localhost:3000/health
curl http://localhost:3000/api/tasks

Key Takeaways

Summary

Architecture Benefits

  • Separation of concerns - each layer has one job
  • Testability - test business logic without infrastructure
  • Maintainability - changes isolated to specific layers
  • Flexibility - swap implementations easily

Layer Rules

  • Presentation - HTTP only, no business logic
  • Application - orchestration, no domain rules
  • Domain - pure business logic, no dependencies
  • Infrastructure - implements domain interfaces

Remember: Dependencies flow downward. The domain layer is the heart of your application.

Questions?

Resources