Chapter 1: Your First Aggregate
In this chapter you will create the foundation of our online bookstore,
Bookshelf. By the end, you will have a working Book aggregate that
you can create, persist, and retrieve — all running in-memory with zero
infrastructure setup.
What We're Building
Over the course of this tutorial we will build a complete online bookstore that manages books, orders, and inventory. Here is a preview of the domain model we are working toward:
graph TB
subgraph "Book Aggregate"
B[Book]
M[Money VO]
B --> M
end
subgraph "Order Aggregate"
O[Order]
OI[OrderItem Entity]
A[Address VO]
O --> OI
O --> A
end
subgraph "Inventory Aggregate"
I[Inventory]
end
subgraph "Projections"
BC[BookCatalog]
OS[OrderSummary]
end
We will start simple — just a Book aggregate with a few fields — and
layer on complexity chapter by chapter.
Setting Up
Create a new directory for the project and install Protean:
mkdir bookshelf
cd bookshelf
pip install protean
Now create a file called bookshelf.py. Every Protean application begins
with a Domain — the central registry for all your business logic:
from protean import Domain
domain = Domain()
Protean ships with in-memory adapters for databases, brokers, and event stores, so you can focus entirely on domain modeling without setting up any infrastructure.
Defining the Book Aggregate
Aggregates are the core building blocks of a Protean domain. They hold state, enforce business rules, and act as consistency boundaries — every change to the data within an aggregate is persisted as a single unit.
Let's model a Book:
from protean import Domain
from protean.fields import Float, String
domain = Domain()
@domain.aggregate
class Book:
title = String(max_length=200, required=True)
author = String(max_length=150, required=True)
isbn = String(max_length=13)
price = Float()
A few things to note:
- The
@domain.aggregatedecorator registersBookwith the domain. - Fields define the aggregate's data.
StringandFloatare two of the many field types Protean provides. required=Truemeans the field must be present when creating aBook.max_lengthconstrains the string length.- Every aggregate automatically gets an
idfield — a unique identifier generated for you.
What Is an Aggregate?
In Domain-Driven Design, an aggregate is a cluster of related
objects treated as a single unit. The aggregate's root entity (here,
Book) is the only entry point for modifications. This ensures that
business rules are always enforced consistently.
For now, our Book aggregate is simple — just the root entity with
a few fields. In later chapters we will add child entities, value
objects, and invariants to make it richer.
Creating a Book
With the aggregate defined, let's create an instance. Add this to the
bottom of bookshelf.py:
domain.init(traverse=False)
if __name__ == "__main__":
with domain.domain_context():
book = Book(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
isbn="9780743273565",
price=12.99,
)
print(f"Created: {book.title} by {book.author}")
print(f"ID: {book.id}")
Two important steps happen here:
-
domain.init()initializes the domain — resolving references, validating the model, and setting up adapters. Thetraverse=Falseflag tells Protean we have registered all elements ourselves (in a larger project with multiple files, you would omit this flag and let Protean auto-discover elements). -
domain.domain_context()activates the domain for the current block. Inside this context, Protean knows which domain is active and can route operations correctly.
Run it:
$ python bookshelf.py
Created: The Great Gatsby by F. Scott Fitzgerald
ID: 5eb04301-f191-4bca-9e49-8e5a948f07f6
The ID is a UUID generated automatically. Every aggregate instance gets a unique identity the moment it is created.
Persisting and Retrieving
Creating a Book object gives you an in-memory instance, but it is not
persisted yet. To save and retrieve books, use a repository:
with domain.domain_context():
# Create a book
book = Book(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
isbn="9780743273565",
price=12.99,
)
print(f"Created: {book.title} by {book.author}")
print(f"ID: {book.id}")
# Persist it
repo = domain.repository_for(Book)
repo.add(book)
# Retrieve it
saved_book = repo.get(book.id)
print(f"Retrieved: {saved_book.title} (${saved_book.price})")
# Verify
assert saved_book.title == "The Great Gatsby"
assert saved_book.author == "F. Scott Fitzgerald"
domain.repository_for(Book)returns a repository bound to theBookaggregate. You don't need to define one — Protean provides a default repository backed by the in-memory adapter.repo.add(book)persists the book. In the default in-memory adapter, this stores the object in a dictionary. With a real database, this would insert a row.repo.get(book.id)retrieves the book by its identifier.
No Database Required
Everything runs in-memory right now. When you are ready for a real database, you will swap in a different adapter through configuration — your domain code stays exactly the same. We will do this in Chapter 12.
Exploring in the Shell
Protean includes an interactive shell that pre-loads your domain. Try it:
$ protean shell --domain bookshelf
Inside the shell, the domain is already initialized and activated. You can create books, persist them, and query — all interactively:
>>> book = Book(title="1984", author="George Orwell", price=9.99)
>>> repo = domain.repository_for(Book)
>>> repo.add(book)
>>> retrieved = repo.get(book.id)
>>> retrieved.title
'1984'
The shell is a great way to experiment as you build out the domain.
What Just Happened?
Let's recap the pieces and how they fit together:
sequenceDiagram
participant You
participant Domain
participant Book as Book Aggregate
participant Repo as Repository
You->>Domain: domain.init()
You->>Book: Book(title="...", author="...")
Book-->>You: book instance (with auto ID)
You->>Repo: repo.add(book)
Repo-->>Repo: Store in memory
You->>Repo: repo.get(book.id)
Repo-->>You: saved book
- You defined a Domain — the container for your business logic.
- You defined a Book aggregate with fields that describe its data.
- You created a Book instance, which got an auto-generated ID.
- You persisted it through a repository and retrieved it back.
All of this ran in-memory with no infrastructure. In the next chapter, we will explore the full field system and learn how identity works.
Full Source
from protean import Domain
from protean.fields import Float, String
domain = Domain()
@domain.aggregate
class Book:
title = String(max_length=200, required=True)
author = String(max_length=150, required=True)
isbn = String(max_length=13)
price = Float()
domain.init(traverse=False)
if __name__ == "__main__":
with domain.domain_context():
# Create a book
book = Book(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
isbn="9780743273565",
price=12.99,
)
print(f"Created: {book.title} by {book.author}")
print(f"ID: {book.id}")
# Persist it
repo = domain.repository_for(Book)
repo.add(book)
# Retrieve it
saved_book = repo.get(book.id)
print(f"Retrieved: {saved_book.title} (${saved_book.price})")
# Verify
assert saved_book.title == "The Great Gatsby"
assert saved_book.author == "F. Scott Fitzgerald"
assert saved_book.price == 12.99
print("All checks passed!")