Back to Blog

Why ORM Models Don't Scale

- 6 min read

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