Active Record is arguably the most popular ORM pattern in web development. SQLAlchemy's models, Django's ORM, Rails' ActiveRecord - they all follow the same idea. And for simple applications, it works beautifully. The problems start when complexity grows.
The Active Record Pattern
In Active Record, a single class is responsible for:
- Representing a row in the database
- Data validation
- Business logic
- Persistence (save, delete, find)
- Querying other records
This is a lot of responsibilities for one class. Here's a typical SQLAlchemy model:
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey("customers.id"))
status = Column(String)
total = Column(Integer)
customer = relationship("Customer", back_populates="orders")
items = relationship("OrderItem", back_populates="order")
def ship(self, session: Session) -> None:
if self.status != "paid":
raise Exception("Cannot ship unpaid order")
self.status = "shipped"
self.shipped_at = datetime.now()
session.commit()
# Send notification
send_order_shipped_email(self.customer.email, self)
def calculate_total(self) -> int:
return sum(item.price * item.quantity for item in self.items) Problem 1: Anemic or Bloated Models
Active Record forces a choice: either your models become bloated with business logic, or you extract that logic elsewhere and your models become anemic (just getters/setters).
Bloated models are hard to test because they depend on the database. Anemic models scatter business logic across services, tasks, and routes.
Problem 2: The Database is Always Present
You can't create an Order without thinking about the database:
# This requires a database session
order = Order(customer_id=1)
session.add(order)
session.commit()
# Testing requires database setup
def test_order_can_be_shipped():
session = create_test_session()
order = Order(customer_id=1, status="paid")
session.add(order)
session.commit()
order.ship(session)
# Requires database, email mocks, etc. Problem 3: No Aggregate Boundaries
In a real domain, an Order and its Items should be modified together as a unit (an Aggregate). With Active Record, nothing prevents this:
# Anyone can modify items directly
item = session.get(OrderItem, 123)
item.quantity = 999
session.commit()
# Order total is now wrong! There's no way to enforce that Order must recalculate its total when items change.
Problem 4: Implicit Dependencies Everywhere
ORM models can query anything:
class Order(Base):
def ship(self, session: Session) -> None:
# Why does Order know about Warehouse?
warehouse = session.query(Warehouse).filter_by(
region=self.shipping_address.region
).first()
# And Shipping rates?
rate = calculate_shipping_rate(warehouse, self)
# And inventory?
reserve_inventory(session, self.items) These implicit dependencies make the code hard to understand and test.
The Alternative: Domain Models + Repositories
Separate the domain logic from persistence:
# Pure domain object - no database awareness
@dataclass
class Order:
id: OrderId
customer_id: CustomerId
status: OrderStatus
items: list[OrderItem] = field(default_factory=list)
def add_item(self, product: Product, quantity: int) -> None:
self.items.append(OrderItem(product, quantity))
self._recalculate_total()
def ship(self) -> None:
if not self.status.can_transition_to(OrderStatus.SHIPPED):
raise CannotShipOrderError(self.status)
self.status = OrderStatus.SHIPPED
self.record_event(OrderWasShipped(self.id))
# Repository handles persistence
class OrderRepositoryInterface(Protocol):
def save(self, order: Order) -> None: ...
def find_by_id(self, id: OrderId) -> Order | None: ...
# Test without database
def test_order_can_be_shipped():
order = Order(
id=OrderId("123"),
customer_id=CustomerId("456"),
status=OrderStatus.PAID,
)
order.ship()
assert order.status == OrderStatus.SHIPPED When Active Record is Fine
Active Record works well for:
- Simple CRUD applications
- Prototypes and MVPs
- Admin panels and back-office tools
- Read-heavy operations (use it for queries!)
The pattern breaks down when:
- Business logic is complex
- You need to enforce invariants
- Multiple entities must change together
- You want fast, isolated unit tests
Ready to move beyond Active Record?
Pragmatic DDD with Python shows you how to use both patterns: SQLAlchemy for simple queries, Domain Models for complex business logic. Get the best of both worlds.
Get the book