Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 041 / 100

Data Mapper vs Active Record: what developers should know

Two ways to connect objects to databases.

Expona Admin9 min read

Data Mapper vs Active Record: Two Architectural Approaches to Persistence

When you're building a production system, one of the earliest architectural decisions you'll face is how to connect your domain objects to the database. Two patterns dominate this space: Active Record and Data Mapper. I've used both extensively across e-commerce platforms, CRM systems, and trading applications, and the choice between them has real consequences for maintainability, testability, and how your codebase evolves.

In this post, I'll walk you through both patterns, show you concrete examples, and help you understand when each makes sense. The tension between these two approaches isn't about which is objectively "better"—it's about recognizing the tradeoffs and making an intentional choice for your context.

Active Record: Objects Save Themselves

Active Record is straightforward. Your domain object knows how to persist itself. The User model, for example, has methods like save(), delete(), and update(). When you call these methods, the object communicates directly with the database.

I first encountered this pattern seriously when working on a rapid e-commerce MVP where we needed to ship features fast. TypeORM with Active Record mode felt natural. The model itself became a complete unit—data structure plus persistence behavior.

Here's what that looks like:

typescript
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  @Column()
  name: string;

  @Column({ default: false })
  isActive: boolean;

  async save() {
    return await User.save(this);
  }

  async delete() {
    return await User.remove(this);
  }

  async updateEmail(newEmail: string) {
    this.email = newEmail;
    await this.save();
    return this;
  }
}

// Usage
const user = new User();
user.email = 'john@example.com';
user.name = 'John Doe';
await user.save();

await user.updateEmail('john.new@example.com');

The appeal is obvious. I can create a user, modify it, and persist it without thinking about repositories or mappers. For CRUD-heavy operations, this is genuinely fast to develop. Your entity class becomes your complete interface to persistence.

Where I've seen Active Record shine is in smaller applications, rapid prototypes, and systems where the database schema is relatively stable and straightforward. A content management system with Users, Posts, and Comments? Active Record handles that elegantly.

Data Mapper: Separate Mappers Persist Objects

Data Mapper inverts this relationship. Your domain objects know nothing about persistence. They're plain objects that represent business concepts. Separate mapper classes handle all database communication.

I moved toward this pattern when I started working on a complex trading platform where business logic needed to remain testable and independent from database concerns. The domain model had to be pure—no framework dependencies, no database knowledge.

Here's the same User concept using Data Mapper:

typescript
// Pure domain model
export class User {
  constructor(
    public id: number,
    public email: string,
    public name: string,
    public isActive: boolean
  ) {}

  updateEmail(newEmail: string) {
    this.email = newEmail;
  }

  deactivate() {
    this.isActive = false;
  }
}

// Mapper handles persistence
import { Repository } from 'typeorm';
import { UserEntity } from './user.entity';

export class UserMapper {
  constructor(private repository: Repository) {}

  async toPersist(user: User): Promise {
    const entity = new UserEntity();
    entity.id = user.id;
    entity.email = user.email;
    entity.name = user.name;
    entity.isActive = user.isActive;
    await this.repository.save(entity);
  }

  async fromPersist(entity: UserEntity): Promise {
    return new User(entity.id, entity.email, entity.name, entity.isActive);
  }

  async findById(id: number): Promise {
    const entity = await this.repository.findOne({ where: { id } });
    return entity ? this.fromPersist(entity) : null;
  }

  async delete(userId: number): Promise {
    await this.repository.delete(userId);
  }
}

// Usage
const user = new User(1, 'john@example.com', 'John Doe', true);
user.updateEmail('john.new@example.com');
await userMapper.toPersist(user);

Notice what's happening here. The User class is a pure domain model. It has no database dependencies, no decorators, no awareness of ORM frameworks. All persistence logic lives in UserMapper. This separation becomes powerful when you're testing business logic—you can instantiate User objects in memory without any database setup.

On a SaaS platform I worked on with complex subscription and billing logic, this separation was invaluable. The domain layer remained focused on business rules. The persistence layer could be refactored, replaced, or tested in isolation.

Active Record is Fast for CRUD

Let me be honest about where Active Record wins: velocity on straightforward CRUD operations. When your primary need is to read, create, update, and delete records without complex business logic, Active Record cuts through ceremony.

During a ride-hailing platform MVP, we used Active Record liberally for entities like Location, VehicleType, and Rating. These didn't need sophisticated domain logic. They needed to move fast from concept to deployed feature. The teams shipping these features appreciated the directness.

Consider a common operation:

typescript
// Active Record approach
const driver = await Driver.findOne({ id: driverId });
driver.rating = 4.8;
driver.totalRides += 1;
await driver.save();

// Data Mapper approach
const driver = await driverMapper.findById(driverId);
driver.updateRating(4.8);
driver.incrementRides();
await driverMapper.toPersist(driver);

The Active Record version is fewer lines, less boilerplate, simpler to understand at a glance. For an MVP or a feature sprint where you're confident the business logic won't explode, this directness is a real advantage.

The catch arrives when business logic grows. When you have validation rules, event emissions, or complex state transitions, Active Record can become a dumping ground for everything—and then your model class is 500 lines of mixed concerns.

Data Mapper Keeps Domain Persistence-Free

The main advantage of Data Mapper is architectural purity. Your domain model is a clean representation of business concepts without framework baggage. This matters more than it might initially seem.

On a complex CRM system I built, the Account entity had sophisticated logic around permissions, hierarchies, and status transitions. The business rules were intricate enough that we needed to reason about them carefully. Having a pure domain model meant we could write tests that exercised all that logic without touching a database:

typescript
describe('Account domain logic', () => {
  it('should not allow activating a suspended account', () => {
    const account = new Account('acme', 'Acme Inc');
    account.suspend('Payment failed');
    
    expect(() => account.activate()).toThrow('Cannot activate suspended account');
  });

  it('should track status transitions', () => {
    const account = new Account('acme', 'Acme Inc');
    account.suspend('Maintenance');
    account.activate();
    
    expect(account.statusHistory).toHaveLength(2);
    expect(account.statusHistory[1]).toEqual({
      status: 'active',
      reason: undefined
    });
  });
});

These tests run instantly. They don't need a database. They're testing pure logic. This becomes invaluable when your domain logic grows complex.

Data Mapper also makes it easier to support multiple persistence backends. On a travel platform, we had some data in PostgreSQL and some in Redis. The domain layer didn't care. We had different mappers for each backend, all implementing the same interface.

There's a maintenance benefit too. Changing your database schema doesn't force changes in your business logic. The mapping layer absorbs the structural changes. This matters when you're evolving a system over years, not weeks.

Common Mistakes

Active Record: Domain Logic Escapes into the Model

The most common mistake I see with Active Record is treating the model class as the dumping ground for all logic related to that entity. Before you know it, you've got:

typescript
export class Order extends BaseEntity {
  // ... properties ...

  async calculateShipping() { /* 20 lines */ }
  async applyDiscount() { /* 30 lines */ }
  async validatePayment() { /* 40 lines */ }
  async processRefund() { /* 50 lines */ }
  async generateInvoice() { /* 35 lines */ }
  async notifyCustomer() { /* 25 lines */ }
}

Your Order model has grown to 300 lines and mixes payment processing, shipping calculations, notification logic, and database queries. This is the Active Record pitfall. Resist it by keeping domain logic in separate service classes even when using Active Record.

Data Mapper: Over-Engineering for Simple Cases

The opposite mistake: using Data Mapper everywhere, even when your entities are genuinely simple. If you're building a basic settings table with three columns, implementing a mapper layer is overhead that doesn't pay for itself.

I've seen projects where every single entity got a mapper, even though 80% of them had no business logic whatsoever. The extra indirection made the code harder to follow without providing any benefit. Know when to keep it simple.

Confusing Repositories with Mappers

Some frameworks blur the line between repositories and mappers. In TypeORM's Active Record mode, the repository is the persistence interface. In Data Mapper mode, the repository is typically where you put query logic, and the mapper handles the object translation. Don't conflate them—understand what each layer is responsible for.

Choosing Between Them

Here's my decision framework:

Use Active Record when: You're building a simple CRUD application, the schema is straightforward, or you're prototyping and want to move fast. MVP mode. Content sites. Admin dashboards.

Use Data Mapper when: Your domain logic is complex, you need to test business rules in isolation, you might swap database backends, or you're building something that will evolve significantly over time. Long-term systems. Complex domains. Shared libraries.

I tend toward Data Mapper on anything that will be maintained for more than a year and touched by more than one team. The upfront structure pays dividends. For proof-of-concepts or simpler projects, Active Record gets me shipping faster.

Key Takeaways

  • Active Record puts persistence methods on domain objects themselves, making CRUD operations straightforward and fast to implement.
  • Data Mapper separates domain objects from persistence concerns entirely, keeping business logic testable and framework-independent.
  • Active Record wins on velocity for simple, CRUD-heavy applications where business logic is minimal.
  • Data Mapper wins on maintainability and testability for complex domains that need to evolve over time.
  • The mistake isn't choosing one over the other—it's choosing and then violating its principles by mixing concerns.
  • Consider your project timeline and complexity. A six-month MVP has different needs than a five-year platform.
  • Hybrid approaches exist: use Active Record for simple entities and Data Mapper for complex ones in the same system.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles