Domain-Driven Design and Hexagonal Architecture for backend services
Forze is a Python toolkit for building backend services with Domain-Driven Design and Hexagonal Architecture.
Your domain and application code imports nothing from a web framework, a database driver, or a transport: it talks to ports, and exactly one place in the service names an adapter. Forze supplies those ports, the runtime that resolves them, and the wiring that keeps the rule true as the service grows.
Quick start
uv add forze
import asyncio
from uuid import UUID
import structlog
from forze import (
CreateDocumentCmd,
Document,
DocumentSpec,
DocumentWriteTypes,
ExecutionContext,
ReadDocument,
build_runtime,
configure_logging,
)
from forze_mock import MockDepsModule
log = structlog.get_logger("hexagon")
# Domain — plain models. Nothing here knows about HTTP, SQL, or a broker.
class Order(Document):
item: str
class CreateOrder(CreateDocumentCmd):
item: str
class ReadOrder(ReadDocument): # adds id, rev, created_at, last_update_at
item: str
# The port — one spec names the aggregate and the types that cross its boundary.
ORDERS = DocumentSpec(
name="orders",
read=ReadOrder,
write=DocumentWriteTypes(domain=Order, create_cmd=CreateOrder),
)
# Application — speaks to the port, never learns which storage answers it.
async def place_order(ctx: ExecutionContext, item: str) -> ReadOrder:
return await ctx.document.command(ORDERS).create(CreateOrder(item=item))
async def read_order(ctx: ExecutionContext, order_id: UUID) -> ReadOrder:
return await ctx.document.query(ORDERS).get(order_id)
async def main() -> None:
# Wiring — the only place an adapter is named. A real backend replaces this module
# (Postgres takes a client, its relation config and a lifecycle module — see
# examples/recipes/crud_fastapi), and nothing above this line changes.
runtime = build_runtime(MockDepsModule())
async with runtime.scope():
ctx = runtime.get_context()
placed = await place_order(ctx, item="widget")
order = await read_order(ctx, placed.id)
log.info("stored and read back", id=str(order.id), item=order.item, rev=order.rev)
if __name__ == "__main__":
# Configure logging only when run as a script, so imports and tests stay unaffected.
configure_logging(level="info", logger_names=["hexagon", "forze"])
asyncio.run(main())
That file is examples/hexagon/app.py,
copied verbatim and run by CI on every commit — uv run python -m examples.hexagon.app.
The in-memory adapter it wires needs no extras. A real backend replaces that one module — for
Postgres, a client, the relation config for the aggregate and a lifecycle module, as in
recipes/crud_fastapi
— and the domain, the spec and the two application functions above it stay exactly as they are.
What Forze does not do
- No ORM. Nothing here models tables, relations, or migrations. The Postgres integration is a driver-level client behind the same port Mongo, Firestore and the in-memory mock implement; your DDL stays yours.
- No dependency-injection container. Deps are values you register in modules and resolve
by key. Nothing scans your code and nothing autowires by type, so the wiring is something you
can read — and
check_wiringdry-runs every registered operation before you serve traffic. - No web framework, and none in the core. The core installs eleven libraries — none of
them a server, a driver, or a client.
import forzeloads three modules; the runtime is pulled in only when you touch a name that needs it. - No code generation and no scaffolding. There is no
forze newand noforze generate. The optional CLI has two commands —dst(deterministic simulation) andmock(serve an app on in-memory backends) — and neither writes code into your project. - No opinion about your directory layout. The docs suggest one; nothing enforces it. What the library enforces is the dependency direction, not your folder names.
When not to use it
- A service that is CRUD over one table. There is no domain to isolate, and the ports will cost you more than they return.
- A team that has not agreed on its domain vocabulary. Forze gives that agreement a place to live; it cannot substitute for having one.
- You want batteries and one blessed way to do things. That is a full-stack framework, and Forze is deliberately not one — it assumes you assemble the service yourself.
Skills for AI agents using Forze
Forze ships Agent Skills for applications that use Forze as a dependency, so an assistant working in your service repo wires ports, specs, and handlers the way the contracts actually expect rather than inventing a plausible shape.
npx skills add morzecrew/forze # all skills
npx skills add morzecrew/forze@forze-wiring # just one
A few of them:
| Skill | Covers |
|---|---|
forze-wiring |
Runtime, DepsRegistry, lifecycle, governed aggregates, pipeline stages |
forze-framework-usage |
ExecutionContext, ports, transactions, identity context, the query DSL |
forze-domain-aggregates |
Document aggregates, mixins, validators, logical specs, composition DTOs |
forze-deps-consumption |
Plain vs routed deps, route=spec.name, built-in *DepsModule, merge debugging |
forze-custom-deps |
Custom DepKey and DepsModule for private integrations |
All 21, with descriptions, are in skills/README.md.
Writing an adapter
Every port is a Protocol under forze.application.contracts.<plane>. An adapter is a class
that satisfies one — some planes ship a base that does the boilerplate, such as
DocumentAdapter — plus a DepsModule that binds it to the key handlers resolve. That is the
whole mechanism the shipped integrations use; being in this repo buys them nothing extra. The
smallest one to read end to end is
forze_vault; the pattern is
documented in the wiring guide
and the forze-custom-deps skill above.
You do not have to take the contract on faith where it is hardest to satisfy: forze_dst
ships backend-agnostic batteries for transactional isolation (the classic anomalies, with the
verdict each level owes) and for outbox → inbox delivery across a crash. Bring your backend and
run them.
How far the seam is proven, plainly: document, storage and messaging ports each have several independent backends, which is what makes those seams credible. HTTP does not — FastAPI is the only web-framework adapter shipped, so on Litestar, Django-Ninja, Robyn or bare ASGI you are writing the first alternative implementation, and you should expect it to surface one or two places where framework-shaped assumptions leaked into the edge.
Examples
Every example under examples/ is a
module you can run and is executed by a test, so none of them can quietly rot. Most need no
Docker.
| Example | Shows |
|---|---|
hexagon/ |
The slice above: domain, port, wiring, no transport |
quickstart/ |
A CRUD HTTP API over the same document shape |
recipes/order_fulfillment/ |
Saga → aggregate event → outbox → relay → inbox → downstream, in-process |
recipes/analytics_duckdb/ |
A named, typed analytics query over a local data lake |
recipes/mcp_server/ |
An aggregate served over MCP, every operation a tool |
Twenty-odd more recipes — caching, idempotency, realtime, secrets rotation, durable workflows — are listed in examples/README.md.
Documentation
Full documentation: morzecrew.github.io/forze.
Stability
Forze is 0.x, and pre-1.0 here means what SemVer says it means: a minor release may change public contracts. What you get in exchange is a written record — every breaking change lands in CHANGELOG.md naming the contract that moved and the migration it needs, including SQL where a schema is involved.
There is no deprecation window yet: a contract that moves, moves in that release with its note, rather than shipping beside the old one for a cycle first. Read the changelog before upgrading a minor.
Python 3.13 and 3.14 are supported.
Contributing
Contributions, issues, and feature requests are welcome. See CONTRIBUTING.md for details.
Security
Please report security vulnerabilities privately as described in SECURITY.md.
License
Forze is licensed under the MIT License — see LICENSE for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file forze-0.6.0.tar.gz.
File metadata
- Download URL: forze-0.6.0.tar.gz
- Upload date:
- Size: 5.0 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3aa4deef4b3f83fe1a3fb57793750a8a4214c2701d5d20cf3b6e63998df32a8c
|
|
| MD5 |
24ee19960d168def56feb70fa019f116
|
|
| BLAKE2b-256 |
decf0e4c35619aa94a62ef23605445f4200ccad43854b0bed9c145ecc579039a
|
Provenance
The following attestation bundles were made for forze-0.6.0.tar.gz:
Publisher:
release.yaml on morzecrew/forze
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
forze-0.6.0.tar.gz -
Subject digest:
3aa4deef4b3f83fe1a3fb57793750a8a4214c2701d5d20cf3b6e63998df32a8c - Sigstore transparency entry: 2477622092
- Sigstore integration time:
-
Permalink:
morzecrew/forze@cb26cf93eeea63e5211e6bcc4b1a84581b15a929 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/morzecrew
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@cb26cf93eeea63e5211e6bcc4b1a84581b15a929 -
Trigger Event:
push
-
Statement type:
File details
Details for the file forze-0.6.0-py3-none-any.whl.
File metadata
- Download URL: forze-0.6.0-py3-none-any.whl
- Upload date:
- Size: 3.4 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a9c3f9faba773b36d0ab5d43c7ae0e92d50018e361f072521646d75c5f7ebc8d
|
|
| MD5 |
4c0b2be4404e3e4dd336cf7e6d4d5a19
|
|
| BLAKE2b-256 |
8a7bdd23f6067c710bbae5aa847a48b95d7b04744853f79ae3fbe5b7c37a67aa
|
Provenance
The following attestation bundles were made for forze-0.6.0-py3-none-any.whl:
Publisher:
release.yaml on morzecrew/forze
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
forze-0.6.0-py3-none-any.whl -
Subject digest:
a9c3f9faba773b36d0ab5d43c7ae0e92d50018e361f072521646d75c5f7ebc8d - Sigstore transparency entry: 2477622103
- Sigstore integration time:
-
Permalink:
morzecrew/forze@cb26cf93eeea63e5211e6bcc4b1a84581b15a929 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/morzecrew
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@cb26cf93eeea63e5211e6bcc4b1a84581b15a929 -
Trigger Event:
push
-
Statement type: