Clean Architecture with Separate Domain, Database, and API Models

Modern production software systems—regardless of language or framework—must manage complexity, change, and scale. A proven way to achieve this is by separating concerns into distinct models for business logic, persistence, and external interfaces. This principle originates from Clean Architecture, Domain-Driven Design (DDD), Onion Architecture, and Hexagonal Architecture, and is widely adopted across ecosystems including Java, C#, Go, TypeScript, and Python.

At its core, the approach mandates independent representations for:

  • Domain Model — business logic
  • Database Model — persistence mapping
  • API Model (DTO) — external contract

This article explains why the pattern is language-agnostic, when to use it, and demonstrates a production-grade implementation using Python.


Universal Principle: Separation of Concerns

Although syntax varies by language, the architectural rule remains identical:

External Systems
      ↓
Interface Layer (DTOs / Controllers)
      ↓
Application Layer (Use Cases)
      ↓
Domain Layer (Business Models)
      ↓
Infrastructure Layer (Database / External Services)

Dependencies always point inward toward the domain. The domain must not depend on frameworks, transport protocols, or storage technology.


How Different Languages Implement the Same Idea

LanguageDomain ModelDatabase ModelAPI Model
JavaPOJOJPA EntityDTO class
C#Plain classEF Core EntityDTO record
TypeScriptDomain classORM entity (TypeORM/Prisma)Interface / schema
GoStructPersistence structJSON struct
PythondataclassSQLAlchemy modelPydantic model

Despite different tools, the architectural intent is identical.


Why This Pattern Is Production-Grade

Large systems must evolve safely. Without separation, small changes cascade into system-wide breakage.

Key Production Benefits

Independent evolution
Database schema, API contracts, and business rules can change independently.

Security control
Internal fields are never exposed unless explicitly mapped.

Framework independence
Core logic survives technology migrations.

Testability
Domain logic can be tested without infrastructure.

Team scalability
Clear ownership boundaries reduce conflicts.


Common Failure Mode: Single Shared Model

Using one model everywhere may appear efficient but creates tight coupling.

Typical consequences include:

  • Exposing database internals via API
  • Breaking clients when schema changes
  • Serialization of sensitive data
  • Inability to refactor safely
  • Framework lock-in
  • Difficult testing

This anti-pattern is common in early-stage projects and becomes expensive later.


Production Python Example

Python is particularly prone to model mixing because frameworks emphasize rapid development. A disciplined approach is therefore essential in long-lived systems.

Technology Mapping

  • Domain → dataclasses
  • Persistence → SQLAlchemy ORM
  • API → Pydantic models
  • Transport → FastAPI (example)

1. Domain Model (Business Layer)

Pure Python object representing business concepts and rules.

# domain/user.py
from dataclasses import dataclass
from uuid import UUID
import re


@dataclass
class User:
    id: UUID
    name: str
    email: str

    def __post_init__(self):
        if not re.match(r".+@.+\..+", self.email):
            raise ValueError("Invalid email address")

    def change_name(self, new_name: str) -> None:
        if not new_name.strip():
            raise ValueError("Name cannot be empty")
        self.name = new_name

Characteristics

  • No framework dependencies
  • Contains invariants and behavior
  • Stable over time
  • Fully testable in isolation

2. Database Model (Persistence Layer)

Represents how data is stored, not what it means.

# infrastructure/db/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String
from uuid import UUID


class Base(DeclarativeBase):
    pass


class UserEntity(Base):
    __tablename__ = "users"

    id: Mapped[UUID] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(200))
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)

Characteristics

  • Database-specific concerns
  • Indexes, constraints, relationships
  • Can change with schema evolution
  • Not suitable for direct exposure

3. API Models (External Contract)

Defines how clients interact with the system.

# api/schemas.py
from pydantic import BaseModel, EmailStr
from uuid import UUID


class CreateUserRequest(BaseModel):
    name: str
    email: EmailStr


class UserResponse(BaseModel):
    id: UUID
    name: str
    email: EmailStr

Characteristics

  • Validation and serialization
  • Versionable contract
  • Tailored to client needs
  • Safe to expose publicly

4. Mapping Between Models

Explicit transformation prevents leakage across layers.

API Request → Domain

from uuid import uuid4
from domain.user import User

def request_to_domain(dto: CreateUserRequest) -> User:
    return User(
        id=uuid4(),
        name=dto.name,
        email=dto.email
    )

Domain → Database Entity

from infrastructure.db.models import UserEntity

def domain_to_entity(user: User) -> UserEntity:
    return UserEntity(
        id=user.id,
        name=user.name,
        email=user.email
    )

Database Entity → Domain

def entity_to_domain(entity: UserEntity) -> User:
    return User(
        id=entity.id,
        name=entity.name,
        email=entity.email
    )

Domain → API Response

from api.schemas import UserResponse

def domain_to_response(user: User) -> UserResponse:
    return UserResponse(
        id=user.id,
        name=user.name,
        email=user.email
    )

5. Repository Implementation

Infrastructure adapts storage to domain contracts.

from sqlalchemy.orm import Session

class UserRepository:

    def __init__(self, session: Session):
        self.session = session

    def save(self, user: User) -> User:
        entity = domain_to_entity(user)
        self.session.merge(entity)
        self.session.commit()
        return user

    def get(self, user_id):
        entity = self.session.get(UserEntity, user_id)
        return entity_to_domain(entity) if entity else None

6. Example FastAPI Endpoint

Demonstrates full end-to-end flow.

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

router = APIRouter()

@router.post("/users", response_model=UserResponse)
def create_user(req: CreateUserRequest, db: Session = Depends(get_db)):
    domain_user = request_to_domain(req)

    repo = UserRepository(db)
    saved_user = repo.save(domain_user)

    return domain_to_response(saved_user)

End-to-End Data Flow

Write Operation

JSON → API DTO → Domain Model → DB Entity → Database

Read Operation

Database → DB Entity → Domain Model → API DTO → JSON

Each transition enforces boundaries and prevents accidental coupling.


When This Architecture Is Essential

Use full separation for:

  • Public APIs
  • Microservices
  • Enterprise applications
  • Regulated domains (finance, healthcare)
  • Systems with long lifecycles
  • Multi-team development
  • Complex business rules

When It May Be Overkill

Avoid full rigor for:

  • Small scripts
  • Throwaway prototypes
  • Simple CRUD tools
  • Single-developer short-term projects

A pragmatic approach scales architecture with system complexity.


Industry Perspective

This pattern is not language-specific—it is architectural. Mature systems across technology stacks adopt equivalent structures because they optimize for long-term stability rather than initial convenience.

Organizations building mission-critical software treat explicit model translation as a necessary boundary, not redundant boilerplate.


Conclusion

Separating domain, database, and API models is a universal best practice for production software systems. While implementation details vary across languages, the underlying principle remains constant: isolate business logic from infrastructure and external contracts.

Python’s ecosystem—using dataclasses, SQLAlchemy, and Pydantic—provides an effective toolkit for implementing this architecture in a clean, maintainable manner.

Systems designed with these boundaries are easier to evolve, safer to operate, and more resilient to technological change.


If you want, I can also provide:

    softwarearchtech

    Became Software Architect Check Now

    Leave a Comment

    Your email address will not be published. Required fields are marked *