Chapter 6: Commands and Command Handlers
So far we have been creating aggregates directly and persisting them through repositories. In a real application, state changes come from outside the domain — a user clicking "Add to Catalog," an API call placing an order. Commands formalize these intentions, and command handlers process them.
Why Commands?
Commands separate what should happen from how it happens:
- The outside world says: "Add this book to the catalog"
- The domain decides: how to create the book, what rules to enforce, what events to raise
This separation gives you a clean boundary between your API layer and your domain logic. It also enables asynchronous processing — commands can be queued and processed later.
Defining Commands
A command is an immutable data object that expresses intent. It is named as an imperative — do this:
title = String(max_length=200, required=True)
author = String(max_length=150, required=True)
isbn = String(max_length=13)
price_amount = Float(required=True)
price_currency = String(max_length=3, default="USD")
description = Text()
Key points:
@domain.command(part_of=Book)registers the command and associates it with theBookaggregate.- Commands carry only the data needed to perform the action — no business logic.
- Commands are immutable — once created, they cannot be modified.
Commands vs Aggregates
Commands have similar fields to aggregates, but they are not the same thing. A command carries the request data. The handler decides how to map that onto the aggregate. This indirection lets you change the aggregate's structure without breaking the command's contract.
Command Handlers
A command handler receives a command and orchestrates the change:
@domain.command_handler(part_of=Book)
class BookCommandHandler:
@handle(AddBook)
def add_book(self, command: AddBook) -> Identifier:
book = Book(
title=command.title,
author=command.author,
isbn=command.isbn,
price=Money(
amount=command.price_amount,
currency=command.price_currency,
),
description=command.description,
)
The pattern is always the same:
- Receive the command
- Create or load the aggregate
- Mutate state
- Persist through the repository
The @handle(AddBook) decorator tells Protean which command this method
processes. One handler class can have multiple @handle methods for
different commands.
Processing Commands
To dispatch a command, use domain.process():
book_id = domain.process(
AddBook(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
price_amount=12.99,
)
)
Protean routes the command to the correct handler based on the command's
part_of aggregate and the handler's @handle decorator.
Sync vs Async Processing
By default, commands are processed asynchronously — they are written to a message store and a background server picks them up. For development and testing, switch to synchronous processing:
domain.config["command_processing"] = "sync"
With sync processing, domain.process() executes the handler immediately
and returns the result. We will use async processing when we set up the
Protean server in Chapter 13.
The Unit of Work
Each command handler method runs inside an automatic Unit of Work — a transaction boundary. If the handler completes successfully, changes are committed. If it raises an exception, everything is rolled back.
You don't need to manage transactions yourself. This is one of the key benefits of using commands and handlers — transactional consistency is built in.
Putting It Together
if __name__ == "__main__":
with domain.domain_context():
# Process a command to add a book
book_id = domain.process(
AddBook(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
isbn="9780743273565",
price_amount=12.99,
description="A story of the mysteriously wealthy Jay Gatsby.",
)
)
print(f"Book added with ID: {book_id}")
# The book is now in the repository
book = current_domain.repository_for(Book).get(book_id)
print(f"Retrieved: {book.title} by {book.author}")
print(f"Price: ${book.price.amount} {book.price.currency}")
# Add another book
book_id_2 = domain.process(
AddBook(
title="Brave New World",
author="Aldous Huxley",
isbn="9780060850524",
price_amount=14.99,
description="A dystopian novel set in a futuristic World State.",
)
)
# Verify both books exist
all_books = current_domain.repository_for(Book)._dao.query.all()
print(f"\nTotal books: {all_books.total}")
Run it:
$ python bookshelf.py
Book added with ID: a3b2c1d0-...
Retrieved: The Great Gatsby by F. Scott Fitzgerald
Price: $12.99 USD
Total books: 2
- The Great Gatsby
- Brave New World
All checks passed!
The flow is clean:
sequenceDiagram
participant App
participant Domain
participant Handler as BookCommandHandler
participant Repo as Repository
App->>Domain: domain.process(AddBook(...))
Domain->>Handler: Dispatch command
Handler->>Handler: Create Book aggregate
Handler->>Repo: repo.add(book)
Repo-->>Handler: OK
Handler-->>App: Return book.id
Full Source
from protean import Domain, handle
from protean.fields import Float, Identifier, String, Text, ValueObject
from protean.utils.globals import current_domain
domain = Domain()
domain.config["command_processing"] = "sync"
@domain.value_object
class Money:
currency = String(max_length=3, default="USD")
amount = Float(required=True)
@domain.aggregate
class Book:
title = String(max_length=200, required=True)
author = String(max_length=150, required=True)
isbn = String(max_length=13)
price = ValueObject(Money)
description = Text()
@domain.command(part_of=Book)
class AddBook:
title = String(max_length=200, required=True)
author = String(max_length=150, required=True)
isbn = String(max_length=13)
price_amount = Float(required=True)
price_currency = String(max_length=3, default="USD")
description = Text()
@domain.command_handler(part_of=Book)
class BookCommandHandler:
@handle(AddBook)
def add_book(self, command: AddBook) -> Identifier:
book = Book(
title=command.title,
author=command.author,
isbn=command.isbn,
price=Money(
amount=command.price_amount,
currency=command.price_currency,
),
description=command.description,
)
current_domain.repository_for(Book).add(book)
return book.id
domain.init(traverse=False)
if __name__ == "__main__":
with domain.domain_context():
# Process a command to add a book
book_id = domain.process(
AddBook(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
isbn="9780743273565",
price_amount=12.99,
description="A story of the mysteriously wealthy Jay Gatsby.",
)
)
print(f"Book added with ID: {book_id}")
# The book is now in the repository
book = current_domain.repository_for(Book).get(book_id)
print(f"Retrieved: {book.title} by {book.author}")
print(f"Price: ${book.price.amount} {book.price.currency}")
# Add another book
book_id_2 = domain.process(
AddBook(
title="Brave New World",
author="Aldous Huxley",
isbn="9780060850524",
price_amount=14.99,
description="A dystopian novel set in a futuristic World State.",
)
)
# Verify both books exist
all_books = current_domain.repository_for(Book)._dao.query.all()
print(f"\nTotal books: {all_books.total}")
for b in all_books.items:
print(f" - {b.title}")
# Verify
assert all_books.total == 2
print("\nAll checks passed!")
Summary
In this chapter you learned:
- Commands are immutable data objects that express intent, named as
imperatives (
AddBook,PlaceOrder). - Command handlers receive commands and orchestrate state changes, following the pattern: receive → create/load → mutate → persist.
domain.process()dispatches a command to its handler.- Each handler method runs in a Unit of Work — automatic transaction management.
- Sync processing runs immediately; async queues for the server.
Commands express what should happen. In the next chapter, we will add domain events to record what did happen.