Domain Driven Design: Transaction scripts and active records
When building software, not all problems require a sledgehammer. For straightforward tasks, simplicity and clarity are your best tools.
Today we will learn two patterns for managing simple business logic: Transaction Script and Active Record
Transaction Script
It organizes business logic as a sequence of steps, like following a recipe. Each operation is a self-contained script that either fully succeeds or fully fails, ensuring data consistency.
When to use it?
Simple operations: CRUD tasks
ETL Pipelines: Extract, Transform, Load
Supporting subdomains: Non-core parts of the system (logging, reporting, etc)
A good example would be the scenario in which you have a banking app where a user withdraws money. Two critical steps must happen atomically:
- Deduct the amount from the account balance
- Log the transaction in the audit table
def withdraw(account_id, amount):
try:
db.start_transaction()
acount = db.query("SELECT balance FROM accounts WHERE id = ?", account_id)
if account.balance < amount:
raise InsufficientFundsError()
db.execute("UPDATE accoutns SET balance = balance - ? where ID = ?", amount, account_id)
db.execute("INSERT INTO transactions (account_id, amount, type) VALUES (?, ?, 'withdrawn')", account_id, amount)
db.commit();
except:
db.rollback()
raise
Why this works?
- Atomicity: The transaction ensures both steps succeed or neither does—no partial withdrawals.
- Simplicity: Easy to read and debug — no hidden abstractions
Pitfalls:
- Duplication: Repeated logic across scripts (e.g. balance checks)
- Scalability: Hard to manage if business rules grow complex
Active Record — data and behavior in one package
The active record wraps a database row into an object that includes data and manipulation methods. It’s like a box that can organize its contents.
When to use it?
Data-centric operations: managing user profiles, product catalogs
Moderate complexity: When data structures are nested but business rules are simple
A good example is to imagine a user profile system where each user has an email, password, and profile data. The Active Record handles validation and persistence.
class User
attr_accessor :id, :email, :password_hash
def initialize(email, password)
@email = email
self.password = password
end
def password=(plaintext)
@password_hash = hash_password(plaintext)
end
def save
validate_email_format!
validate_password_strength!
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", @email, @password_hash)
end
private
def validate_email_format!
raise InvalidEmailError unless @email =~ /@/
end
def validate_password_strength!
raise WeakPasswordError if @password_hash.length < 8
end
end
user = User.new("alice@example.com", "s3cur3p@ss")
user.save
Why it works?
- Encapsulation: Validation and persistence logic lives within the object
- Reusability: Methods like save can be reused across the codebase
Pitfalls
- Anemic models: If the object only has getter/setters and no behavior, it’s just a big data bag
- Coupling: Tight integration with the database schema makes migration tricky
How do I choose the right tool for the job?
Here is a simple framework to help you with the
+-----------------+--------------------------------+----------------------------+
| Criteria | Transaction Script | Active Record |
+=================+================================+============================+
| Complexity | Simple, linear workflows | Nested data structure |
+-----------------+--------------------------------+----------------------------+
| Consistency | Requires explicit transactions | Built-in save/update logic |
+-----------------+--------------------------------+----------------------------+
| Maintainability | Prone to duplication | Centralizes data behavior |
+-----------------+--------------------------------+----------------------------+
Key takeaways and advice
Relax, if losing 0,001% of the data won’t break the system, avoid over-engineering.
Both patterns lack the rigor for complex business rules. Save them for supporting tasks.
The transaction script is your go-to for step-by-step operations. Think “scripts, not “systems”.
Active record shines when data and simple behavior are intertwined. Just don’t let it turn into a passive data holder.