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…
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:
- Receives JSON request →
CreateOrderRequest(API model/Pydantic Schema)
Application Layer:
- Converts request →
CreateOrderCommand - Executes use case → calls Domain
Domain Model sLayer:
- Creates
Orderentity and validates business rules
Infrastructure Layer:
- Persists order using ORM model
OrderTable
API Layer:
- Returns
OrderResponseDTO 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