Action-Oriented Architecture

The code
is the graph.

AOA is a Python framework where the business operation is the backbone of the whole architecture. The operation declares for itself who may run it, what it depends on and what happens on failure; a graph is assembled from the catalogue of operations, and it checks itself and keeps infrastructure from bleeding into business logic. A violation is caught at start-up, not in production three weeks later.

pip install aoa-action-machine
getting started
python 3.12+license Apache 2.0CI passingtests 2,555aoa-action-machine 1.0.1a2
orders/actions.py
@meta(domain=OrderDomain)@check_roles(ManagerRole)@depends(PaymentGateway)@compensate(refund_payment)class ChargeOrderAction(BaseAction[Params, Result]):    @result_state(OrderStatus.CONFIRMED)    async def charge_aspect(self, params, state, box):        await self.gateway.charge(params.amount)        return {"status": OrderStatus.CONFIRMED}
walked by the frameworklive, on import
DomainRoleActionGateway

01 — Overview

Architecture lives in the code.
Not in people's heads, not in documents.

Systems don't die from bugs — they die because, over time, people stop understanding them, as the team changes and complexity grows. We're taught to write code, apply patterns, follow style guides — but not how to manage complexity and keep a system readable a year, two, five years on. Every minute spent figuring out "what's going on here" is a minute stolen from progress.

AOA takes a different approach: it moves knowledge about the architecture and the system out of people's heads and out of stale documents — into the code, as machine-readable intent: executable, verifiable, observable.

FIVE LEVELS OF DETAIL

How to walk from understanding the whole system down to the code itself

Each level is a foothold: it answers its own question about what the system is, and asks nothing of what lies deeper.

Explore the five levels ↗
01

Domain

Where does responsibility live?

02

Action

What can the system do?

03

Contract

What does the operation require and promise?

04

Pipeline

How does the scenario unfold?

05

Code

Where does the concrete behavior live?

● derived from the graph
Design-first

Design the model, then the code.

Entities, their relations and their lifecycle are declared as a pure domain model — not bound to an ORM or any particular database. The integrity of the whole model is checked at start-up.

Design the model →
ResourceEntityRelationsLifecyclethe domain, understoodthencode
Params → Result

One class, the whole operation.

Roles, steps, compensations, errors, cache and dependencies are declared in one place — Params in, Result out, and no side doors between them.

transportrolesIoCrollbackcontextconnectionActionParamsResultrolesstepsrollbackcontext
Action trace

Action X-Ray.

See the input, output and duration of every Aspect in one operation — in development, tests or production, without observability code inside business logic.

Open Action X-Ray ↗
ONLINE DEBUG · RUN #A84FLIVE · PRODUCTIONACTION · ONE EXECUTIONParamsvalidatereservechargeResultSTATE IN{ validated_items: 3 }STATE OUTreservation_id · 18.6 msinput · output · error · duration at every boundarydevelopment · tests · production — the same projection
@depends / @connection / @context_requires

Hexagonal architecture ports, not dependencies.

@depends / @connection / @context_requires declare an Action's ports — adapters plug in from outside. box.resolve(T) returns only what's declared.

PaymentGatewayEmailServiceInventoryServiceenv.* · runtime.*request.* · user.*@depends@connection@depends@context_requiresActionbox.resolve(T) — только объявленное
TestBench

Swap the world, not the code.

The real pipeline runs unmodified against a substituted world — user, mocks, context. What passes here is what runs in production.

Test worldmocked gatewayProd worldreal gatewayActionResultsame pipeline — different world

02 — Maxitor

The graph above is real.
Here's the tool that draws it.

Every domain, role and dependency becomes a node the moment it's imported — four different views of the same running system, all unedited exports.

Full graph

Every domain, role, dependency — one graph.

Try Maxitor ↗

Use-case view

Roles and the Actions they reach.

Try Maxitor ↗

Lifecycle FSM

Every reachable status transition.

Try Maxitor ↗

03 — Capabilities

More than a pipeline.

The same declared graph feeds caching, business events, observability and agent frameworks — without a line of glue.

Actionmachinerolescacheeventsrollback

The machine that runs them.

Every Action is run by one machine — roles, cache, events and rollbacks are its doing.

cache_key?cached valueaspectHITMISS

Cache, declared.

cache_key short-circuits straight to the result; a miss runs the aspect and on_cache_write stores it.

ActionOrderCompletedMaxitorOCELDash

Events are facts, not log lines.

box.info(Channel.business, …) emits a typed fact — read by Maxitor, OCEL and dashboards alike.

machineloggerotelocelone way — side-effects only

Observers can't intervene.

Remove every observer and behavior doesn't change. Telemetry is a guarantee, not a hope.

aspectraiseson_errorResultone handler — end of pipeline

One place errors end up.

A single end-of-pipeline handler decides what an operation returns after a failure.

reservereleasechargerefundpersistreverse order — automatic

Sagas roll back on their own.

A failure unwinds completed steps in reverse, each rollback declared beside its step.

check_rolesроль · RBACgrant/guardлёгкий ABACaccess_decide()глубокий ABACmachine.check() → AccessVerdict«могу ли я» — без вызовакаждый уровень может отказать раньше следующего

RBAC first. Then ABAC, ever deeper.

check_roles — pure RBAC: does the role exist. grant.when / guard= — lightweight, call-level ABAC. access_decide() — complex ABAC: object and fact.

OrderDomainActionRoleManagerRoleissubclass = authorityaccess matrixdomain × role — вместе матрица доступа

Domain and Role — the map of the system.

Domain is the assembly point for an operation: graph, access matrix, visualization. Role carries authority through inheritance. Together, they are that matrix.


04 — Extension

The core stays small.
The ecosystem doesn't.

The same declared graph plugs the operation into the outside world: agent frameworks, queues, interfaces, observability. The core doesn't grow — what's around it does.

startplanActionrespondagent loop

A typed node in LangGraph.

LangGraphController drops an Action into an agent graph — same contracts, same rollback.

KafkaplannedFastAPIHTTPMCPAI toolsActionthe adapters translate — the Action doesn't change

One Action. Every front.

An HTTP route and an AI tool call are the same operation, with the same guarantees.

many runsOCELobject-centric logOrderthe real process, discovered from what actually ran

OCEL/OCPM — the real process, not a diagram.

Every run already produces lifecycle events. Process mining sees the real process and the entities involved through them — not a diagram drawn once.

ActionlifecycleeventsX-RayOTel spansone source of events — two live projections

OTel — the same events, as a trace.

The same plugin that builds the online projection for Action X-Ray emits events to OpenTelemetry: one source — traces and a live run at once.

route→ capabilityVerdictallowedreasonexpiresReactmobileFletsame verdict — any UI

Intent-Based UI — not a boolean, but a verdict.

Route → typed capability: verdict() explains the denial, call() invokes it. One contract — React, mobile, Flet.

EntityModelAgentActomdigital twinentity + behavior + agent — one declaration

Digital twin

Actom combines entity, behavior, and agent in a single declaration — a stateful actor, observable through Action X-Ray. Files become a graph twin of the system.


05 — Concepts

Five topics.
Each will become its own page.

Five independent angles on one system — from the grammar of declarations to AOA's place among other frameworks.

01

Enforced grammar

Grammar turns every rule into a declaration (@check_roles, @depends, validators, naming rules); on import, these declarations merge into a graph — so the code is the specification, and the graph is its machine-readable form: checked at startup, drawn in Maxitor, tracked by Action X-Ray — not documentation floating somewhere near the code.

02

The AOA Constitution

Eight primitives and a set of mandatory declarations — not a style convention, but a law the machine checks on every import. You can't break it quietly: a violation stops the run, it doesn't surface in production.

03

Separation of business logic and infrastructure

Action knows the domain and the rules; how to reach Postgres, Kafka, or a third-party API is known by the adapter behind the Gateway or Resource interface. Business code never imports drivers directly — the boundary runs through the declaration, not through an agreement between developers.

04

Principles of intent-oriented programming

Code declares intent — what should happen and under what conditions — not a sequence of calls into specific libraries. The implementation is plugged in through ports (@depends, @connection) and can change while the intent stays the same.

05

Comparison with other frameworks

FastAPI and Django give you routing and an ORM, but they don't check roles, dependencies, and state transitions at import time. LangGraph explicitly builds a graph, but for orchestrating LLM steps, not business operations. AOA sits closer to both at once — an executable graph of application architecture, not just transport or a model pipeline.


06 — Practical recipes

One task.
One recipe.

Task-oriented, not tutorial-oriented — you already know what you're building. These say how.

how-to/authoring-action

The skeleton of an Action: Params, the aspect pipeline, checkers, connections.

how-to/choosing-primitive

A decision guide for the primitive that actually fits what you're building.

how-to/authoring-resource

Wrap a database, queue, or SDK as a managed, injectable dependency.

how-to/authoring-adapter

Expose existing Actions over a new protocol, without touching them.

how-to/authoring-cache-adapter

Plug a different cache backend into cache_key / on_cache_write.

how-to/authoring-plugin

Observe the machine's lifecycle events, with no right to intervene.

how-to/authoring-auth-coordinator

Wire a custom authentication scheme into @check_roles.

how-to/authoring-logger

Route box.info / box.warning to wherever your logs actually live.

how-to/authoring-intent

Declare a new participation grammar other primitives can opt into.

how-to/extending-context

Add your own slice to what an Action can see about the call.

how-to/migrating-legacy

Bring an existing codebase in gradually, Action by Action.


07 — faq

Questions, answered.

Isn't this just Clean Architecture with more decorators?

The difference is enforcement. Clean Architecture is a set of conventions a reviewer checks by hand. AOA's contracts — @check_roles, @depends, state checkers — are checked by the machine before the operation runs, not by whoever remembers to look.

Do I have to rewrite my existing FastAPI or Django app?

No. aoa-fastapi-adapter exposes existing Actions as ordinary routes. You adopt Action by Action, not framework by framework.

What does the aspect pipeline cost at runtime?

Each aspect is a plain async call — no serialization, no process boundary. The cost is the checks you asked for, not the framework around them.

Do I need Maxitor to use AOA?

No. Maxitor reads the same Domains, Roles and Actions you already declared — it's a viewer, not a dependency. AOA runs without it.

How is @compensate different from a database transaction?

A transaction rolls back one datastore. @compensate rolls back a business operation that may have touched a payment gateway, an email service and a database — none of which share a transaction boundary.

When is AOA the wrong tool?

For a script, a one-off CLI tool, or a service with a single operation and no compliance requirement, plain functions are the right call.

⭐ If you like AOA, star it on GitHub and join the discussion on GitHub Discussions.

AOA — The code is the graph.