Back to Blog

Why Active Record Doesn't Scale

- 6 min read

Active Record is arguably the most popular ORM pattern in web development. Laravel's Eloquent, Rails' ActiveRecord, Django's ORM - 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 Eloquent model:

class Order extends Model
{
    protected $fillable = ['customer_id', 'status', 'total'];

    public function customer() {
        return $this->belongsTo(Customer::class);
    }

    public function items() {
        return $this->hasMany(OrderItem::class);
    }

    public function ship(): void
    {
        if ($this->status !== 'paid') {
            throw new Exception('Cannot ship unpaid order');
        }

        $this->status = 'shipped';
        $this->shipped_at = now();
        $this->save();

        // Send notification
        Mail::to($this->customer->email)
            ->send(new OrderShipped($this));
    }

    public function calculateTotal(): Money
    {
        return $this->items->sum(fn($item) =>
            $item->price * $item->quantity
        );
    }
}

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, jobs, and controllers.

Problem 2: The Database is Always Present

You can't create an Order without thinking about the database:

// This hits the database
$order = new Order(['customer_id' => 1]);
$order->save();

// So does this
$order = Order::create(['customer_id' => 1]);

// And testing requires database setup
public function test_order_can_be_shipped()
{
    $order = Order::factory()->create(['status' => 'paid']);
    $order->ship();
    // Requires database, mail fake, 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 = OrderItem::find(123);
$item->quantity = 999;
$item->save();
// 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

Active Record models can query anything:

class Order extends Model
{
    public function ship(): void
    {
        // Why does Order know about Warehouse?
        $warehouse = Warehouse::findNearest($this->shipping_address);

        // And Shipping rates?
        $rate = ShippingRate::calculate($warehouse, $this);

        // And inventory?
        Inventory::reserve($this->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
final class Order
{
    private array $items = [];
    private OrderStatus $status;

    public function addItem(Product $product, int $quantity): void
    {
        $this->items[] = new OrderItem($product, $quantity);
        $this->recalculateTotal();
    }

    public function ship(): void
    {
        if (!$this->status->canTransitionTo(OrderStatus::Shipped)) {
            throw new CannotShipOrderException($this->status);
        }

        $this->status = OrderStatus::Shipped;
        $this->recordEvent(new OrderWasShipped($this->id));
    }
}

// Repository handles persistence
interface OrderRepositoryInterface
{
    public function save(Order $order): void;
    public function findById(OrderId $id): ?Order;
}

// Test without database
public function test_order_can_be_shipped(): void
{
    $order = new Order(/*...*/);
    $order->markAsPaid();

    $order->ship();

    $this->assertEquals(OrderStatus::Shipped, $order->status());
}

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 Laravel shows you how to use both patterns: Active Record for simple queries, Domain Models for complex business logic. Get the best of both worlds.

Get the book