Chapter 1: Your First Aggregate
In this chapter we will create the foundation of our online bookstore,
Bookshelf. By the end, we will have a working Book aggregate that
we can create, persist, and retrieve — all running in-memory with zero
infrastructure setup.
Over the course of this tutorial we will build a complete bookstore that
manages books, orders, and inventory. We start with just a Book.
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
An aggregate is a cluster of related objects treated as a single unit — the core building block of a Protean domain (see Aggregates for more).
Let's define 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()
Notice that:
- 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.
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
Notice that 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:
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!")
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.
The output should look like:
Retrieved: The Great Gatsby ($12.99)
All checks passed!
Everything runs in-memory right now. When we are ready for a real database, we will swap in a different adapter through configuration — domain code stays exactly the same. We will do this in Chapter 8.
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 We Built
- A Domain — the container for our business logic.
- A Book aggregate with fields that describe its data.
- Created a Book instance, which got an auto-generated ID.
- Persisted it through a repository and retrieved it back.
All of this ran in-memory with no infrastructure. In the next chapter, we will add richer fields and create value objects for price and address.
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!")