Skip to content

Architecture Proposal: DDD Layered Architecture with Clear Separation of Concerns #2

@deepracticexs

Description

@deepracticexs

Background

Currently, the src/ directory structure has some architectural inconsistencies:

  1. Naming confusion: core/ suggests domain core, but it actually contains infrastructure components (persistence, crypto, mail, jwt)
  2. Missing layer: No application service layer - API routes directly instantiate domain services and infrastructure
  3. Leaky abstraction: API layer imports from both domain/ and core/, bypassing proper encapsulation
  4. Export complexity: src/package.json exports many sub-paths, exposing internal implementation details

Example from services/account-api/src/routes/account.ts:

import { UserService } from 'edgeauth/domain/user';
import { D1UserRepository, hashPassword } from 'edgeauth/core';
import { PlunkSender, emailVerificationTemplate } from 'edgeauth/core/mail';

This violates DDD principles where API should only depend on application services.

Proposed Architecture

Adopt standard DDD layered architecture with clear separation:

src/
├── infrastructure/     # Infrastructure Layer (renamed from core/)
│   ├── persistence/    # D1Repository, KVStore, etc.
│   ├── crypto/         # Password hashing, encryption
│   ├── mail/           # Email sending (Plunk, templates)
│   └── jwt/            # JWT generation and verification
│
├── domain/             # Domain Layer (keep as-is, with refinements)
│   ├── user/           # User aggregate + UserService (domain logic)
│   ├── oauth/          # OAuth aggregate + OAuthService
│   ├── sso/            # SSO aggregate + SSOService
│   └── verification/   # Verification logic
│
├── application/        # Application Layer (NEW - currently missing)
│   ├── AccountService.ts      # User registration, verification, profile
│   ├── AuthService.ts         # Login, token management
│   ├── OAuthService.ts        # OAuth flows orchestration
│   └── ProfileService.ts      # User profile management
│
└── index.ts            # Public API - exports only application services

Layer Responsibilities

Infrastructure Layer (infrastructure/):

  • Technical implementations: database access, crypto, email, JWT
  • Implements interfaces defined by domain layer (dependency inversion)
  • No business logic

Domain Layer (domain/):

  • Pure domain logic: validation, business rules, domain object coordination
  • Domain services for complex logic that doesn't belong to entities
  • Defines repository interfaces (not implementations)
  • No knowledge of infrastructure details

Application Layer (application/):

  • Orchestrates complete business workflows
  • Combines domain services + infrastructure
  • Handles transactions, external integrations, logging
  • Single entry point for API layer

Dependency Rules

API Layer (services/*)
  ↓ depends on
Application Layer (application/)
  ↓ depends on
Domain Layer (domain/) + Infrastructure Layer (infrastructure/)
  ↓
Domain defines interfaces, Infrastructure implements

Benefits

  1. Clear responsibilities: Each layer has well-defined purpose
  2. Better testability: Domain logic can be tested independently
  3. Simplified API: Services only import from edgeauth (application services)
  4. Dependency inversion: Domain defines contracts, infrastructure implements
  5. Easier maintenance: Changes in infrastructure don't affect domain logic
  6. Standard DDD: Follows established architectural patterns

Migration Steps

  1. Rename core/ to infrastructure/
  2. Create application/ directory with service implementations
  3. Update src/index.ts to only export application services and necessary types
  4. Simplify src/package.json exports to only expose "."
  5. Update all API services to import only from edgeauth package
  6. Update all import paths throughout the codebase

Example: Before vs After

Before (API directly using domain + infrastructure):

// services/account-api/src/routes/account.ts
import { UserService } from 'edgeauth/domain/user';
import { D1UserRepository, hashPassword } from 'edgeauth/core';
import { PlunkSender, emailVerificationTemplate } from 'edgeauth/core/mail';

account.post('/register', async (c) => {
  const userRepository = new D1UserRepository(c.env.DB);
  const userService = new UserService(userRepository);
  const passwordHash = await hashPassword(body.password);
  const user = await userService.register(data, passwordHash);
  
  const mailSender = new PlunkSender(c.env.PLUNK_API_KEY, ...);
  await mailSender.send({...});
  // ... 40+ lines of orchestration logic
});

After (API using application service):

// services/account-api/src/routes/account.ts
import { AccountService } from 'edgeauth';

account.post('/register', async (c) => {
  const accountService = new AccountService({
    db: c.env.DB,
    jwtSecret: c.env.JWT_SECRET,
    plunkApiKey: c.env.PLUNK_API_KEY,
    emailFrom: c.env.EMAIL_FROM,
    baseUrl: c.env.BASE_URL,
  });
  
  const result = await accountService.register({
    email: body.email,
    username: body.username,
    password: body.password,
  });
  
  return c.json(result, 201);
});

Questions for Discussion

  1. Should we introduce a factory pattern for service instantiation?
  2. How should we handle dependency injection for application services?
  3. Should domain/ services define their own repository interfaces, or use a shared repository pattern?
  4. Timeline and migration strategy for existing services?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions