← Back to list

Software Architecture Design Components — Models

In Domain-Driven Design (DDD), Layered Architecture, and Hexagonal Architecture, the term “model” can refer to different kinds of objects…

Hector · 2025-11-05 21:06 · 2 claps · 4.9 min read
#layered-architecture #hexagonal-architecture #software-development #domain-model #software-architecture
Open on Medium ↗
Wiki topics: 🏛️ · Architecture

Software Architecture Design Components — Models

In Domain-Driven Design (DDD), Layered Architecture, and Hexagonal Architecture, the term “model” can refer to different kinds of objects that represent data, behavior, or both — depending on where in the system they live.

Each layer (or hexagonal side) has its own model — and each model serves a different purpose.

🎯 The Bottom Line

Schemas ask: “Is this data formatted correctly?” Domain models ask: “Does this data make business sense in our context?”

The same validation (like email format) might appear in both, but:

  • Schema does it for data integrity
  • Domain model does it for business reasons

This separation becomes crucial when business rules get complex and involve multiple data sources, external systems, or complex decision trees!

Example Flow Use case: “Create a new order”

API Layer:

Application Layer:

  • Converts request → CreateOrderCommand
  • Executes use case → calls Domain

Domain Model sLayer:

  • Creates Order entity and validates business rules

Infrastructure Layer:

  • Persists order using ORM model OrderTable

API Layer:

  • Returns OrderResponse DTO to client

Schemas (Data Validation & Serialization) → API Contract Definition

A schema is a data validation and serialization framework that defines the structure, constraints, and rules for your data. its core purpose is to:

  • Validation: Ensure incoming data meets requirements
  • Serialization: Convert data to/from Python types and JSON
  • Documentation: Self-documenting API contracts
  • Security: Type safety and input sanitization
  • API Contract Definition: Schemas define the shape of data entering and leaving your system. They handle validation, serialization, and API contract enforcement.

Schema Validations (Pydantic)

  • Format checking: “Is this a valid email format?”
  • Type checking: “Is this a positive number?”
  • Basic constraints: “Is this string between 2–100 chars?”
  • Simple, standalone rules
# app/api/schemas.py
from pydantic import BaseModel, EmailStr, validator
from typing import Optional
from datetime import datetime

class CustomerCreate(BaseModel):
    """Schema for creating a customer via API"""
    email: str
    name: str
    initial_balance: float = 0.0

    @validator('initial_balance')
    def validate_initial_balance(cls, v):
        if v < 0:
            raise ValueError("Initial balance cannot be negative")
        return v
class CustomerResponse(BaseModel):
    """Schema for returning customer data via API"""
    customer_id: str
    email: str
    name: str
    balance: float
    is_active: bool
    loyalty_tier: str
    created_at: datetime
class PurchaseRequest(BaseModel):
    """Schema for purchase API request"""
    amount: float

    @validator('amount')
    def validate_amount(cls, v):
        if v <= 0:
            raise ValueError("Amount must be positive")
        return v

Domain Models (Business Logic Layer)

Domain Models contain your core business logic, validation rules, and business behaviors. They are completely isolated from infrastructure concerns and can be tested without any external dependencies.

Key Principles:

  • Contain business rules and validation logic
  • Are persistence-agnostic (no database concerns)
  • Can be serialized/deserialized independently
  • Are the single source of truth for business rules

Domain Validations (Domain Models)

  • Business rules: “Can this customer get a loan based on our risk model?”
  • Multi-field logic: “If credit score is X AND income is Y THEN…”
  • External data: “Check against our fraud database”
  • Business processes: “If approved, notify compliance team”
  • Complex, contextual rules
# In Domain models - these are BUSINESS validations
class Customer:
    def _validate_email(self, email: str) -> str:
        """Business context: Can this email be used in OUR business?"""
        if not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
            raise ValueError("Invalid email format")

        # BUSINESS RULES that schemas don't care about:
        if email.endswith('.ru') and self._country != 'Russia':
            raise ValueError("Russian emails only allowed for Russian customers")

        if 'temp' in email.lower():
            raise ValueError("Temporary emails not allowed for security")

        return email.lower()

    def _validate_name(self, name: str) -> str:
        """Business context: Is this name valid for OUR business processes?"""
        if len(name.strip()) < 2:
            raise ValueError("Name must be at least 2 characters")

        # BUSINESS RULES:
        if any(profanity in name.lower() for profanity in ['badword1', 'badword2']):
            raise ValueError("Name contains inappropriate content")

        if name.upper() == name:  # ALL CAPS
            raise ValueError("All-caps names not allowed - potential fraud")

        return name.strip()

Persistence DB Models (Infrastructure Layer)

SQLAlchemy models are purely for data persistence — they define your database schema, relationships, and optimizations. They should contain NO business logic.

  • ✅ Database schema definition
  • ✅ Complex relationships and joins
  • ✅ Database-specific optimizations
  • ❌ Business logic and validation
  • ❌ API serialization concerns
  • ❌ Domain behaviors
from typing import List
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship

class Base(DeclarativeBase):
    pass

class Customer(Base):
    __tablename__ = "user_account"
    customer_id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    email: Mapped[Optional[str]]
    addresses: Mapped[List["Address"]] = relationship(
        back_populates="user", cascade="all, delete-orphan"
    )
    def __repr__(self) -> str:
        return f"User(id={self.id!r}, name={self.name!r}, fullname={self.fullname!r})"

class Address(Base):
    __tablename__ = "address"
    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    user: Mapped["User"] = relationship(back_populates="addresses")
    def __repr__(self) -> str:
        return f"Address(id={self.id!r}, email_address={self.email_address!r})"

Repository (Mapping Between Layers)

The Repository pattern acts as a bridge between the domain layer and the infrastructure layer, allowing clean separation between business logic and data persistence. It ensures that the domain model remains independent from any database or ORM framework.

In the example below, the CustomerRepository class handles the translation between two representations of a customer:

  • Domain model (Customer) – represents business logic and behavior.
  • Persistence model (CustomerModel) – represents how data is stored in the database (e.g., an ORM entity).
from .models import CustomerModel
from domain.models import Customer

class CustomerRepository:
    """Maps between domain models and persistence models"""

This repository sits in the infrastructure layer and depends on both the database model and the domain model, but never the other way around. The domain layer should not import infrastructure code — maintaining the dependency inversion principle.

Mapping from Persistence → Domain

def to_domain(self, db_customer: CustomerModel) -> Customer:
    """Convert persistence model to domain model"""
    customer = Customer(
        customer_id=db_customer.customer_id,
        email=db_customer.email,
        name=db_customer.name,
        balance=db_customer.balance
    )
    # Note: We might need to set internal state if domain model allows
    return customer

Here, the repository translates raw data from the database (CustomerModel) into a rich domain entity (Customer). This ensures that any logic related to the customer’s behavior or state remains encapsulated in the domain model — not mixed with ORM concerns.

Mapping from Domain → Persistence

def to_persistence(self, customer: Customer) -> CustomerModel:
    """Convert domain model to persistence model"""
    return CustomerModel(
        customer_id=customer.customer_id,
        email=customer.email,
        name=customer.name,
        balance=customer.balance,
        is_active=customer.is_active,
        created_at=customer.created_at
    )

When saving a domain entity, the repository performs the reverse transformation: It extracts the necessary fields from the domain object and maps them into a database-compatible model.

# app/infrastructure/repositories.py
from .models import CustomerModel
from domain.models import Customer

class CustomerRepository:
    """Maps between domain models and persistence models"""

    def to_domain(self, db_customer: CustomerModel) -> Customer:
        """Convert persistence model to domain model"""
        customer = Customer(
            customer_id=db_customer.customer_id,
            email=db_customer.email,
            name=db_customer.name,
            balance=db_customer.balance
        )
        # Note: We might need to set internal state if domain model allows
        return customer

    def to_persistence(self, customer: Customer) -> CustomerModel:
        """Convert domain model to persistence model"""
        return CustomerModel(
            customer_id=customer.customer_id,  # Assuming we add getters
            email=customer.email,
            name=customer.name,
            balance=customer.balance,
            is_active=customer.is_active,
            created_at=customer.created_at
        )

Why It Matters?

  • Decoupling: Business logic doesn’t depend on ORM or SQL.
  • Testability: Domain logic can be tested without touching the database.
  • Flexibility: Changing the database or ORM (e.g., from SQLAlchemy to DynamoDB) doesn’t affect the domain layer.
  • Consistency: Ensures a single, well-defined mapping point between layers.

In short, this repository encapsulates data access translation, allowing your domain layer to remain pure, expressive, and persistence-agnostic — one of the core principles of clean architecture.


메타데이터
post_id
6c526ea3aaef
slug
software-architecture-design-components-models-6c526ea3aaef
url
https://medium.com/@hector-reyesaleman/software-architecture-design-components-models-6c526ea3aaef
canonical_url
https://medium.com/@hector-reyesaleman/software-architecture-design-components-models-6c526ea3aaef
author_url
https://medium.com/@hector-reyesaleman
status
ok
fetched_at
2026-08-10 05:06:00