Skip to content

Project Structure

QuadKit does not ask you to pick minimal, structured, or modular up front. quadkit new project lays down one tree. A project that never draws a bounded context simply never has a modules/<slug>/ directory. Adopting one later is not a migration — only the nodes you scope into it move.

There is no --structure flag and no [tool.quadkit] structure key.


  1. Is this component cross-cutting? Errors, middleware, providers, health, schema — one per application. They land in src/<app>/shared/<component>/ and stay there, whether or not the node belongs to a module.
  2. Is this node in a module? A module-local component lands in src/<app>/<component>/ while the node is unscoped, and in src/<app>/modules/<slug>/<component>/ the moment it joins a module.

The composition root is always src/<app>/app.py. The ASGI target is always <app>.app:app ([tool.quadkit] module in pyproject.toml).

shared/ means cross-cutting. Unscoped feature code sits at the app package root, not in shared/, so nothing has to be moved out of shared/ later.


Terminal window
quadkit new project my-app --template web-api

Templates (minimal, api, web-api, graphql, worker, full) change which packages and application.yaml sections you get. They do not change the tree.

my-app/
├── application.yaml
├── pyproject.toml # [tool.quadkit] module = "my_app.app:app"
├── README.md
├── .env.example
├── migrations/versions/ # quadkit gen migration
├── seeds/ # quadkit gen seeder
├── src/
│ └── my_app/
│ ├── __init__.py
│ ├── app.py # create_app() — composition root
│ ├── py.typed
│ ├── controllers/ # unscoped; quadkit gen controller …
│ ├── infrastructure/ # db, cache, events
│ ├── shared/ # cross-cutting packages (empty until generated)
│ └── modules/
│ └── __init__.py # empty until quadkit new module
└── tests/
├── conftest.py # boots create_app()
└── test_app.py

A fresh project ships no sample module. Feature directories such as services/, domains/, and di/ appear when you generate them.

Terminal window
quadkit gen controller users # src/my_app/controllers/…
quadkit gen service greetings # src/my_app/services/…
quadkit gen error not_found # src/my_app/shared/errors/… (always shared)

When a feature needs an encapsulation boundary — private services, a public protocol, its own provider — add a module. The rest of the project stays put.

Terminal window
quadkit new module auth
# → src/my_app/modules/auth/{__init__.py, protocols.py, provider.py, services.py}
# → list AuthModule in create_app() next to WebModule

Then generate into it:

Terminal window
quadkit gen controller users --module auth
# → src/my_app/modules/auth/controllers/…

--module is a per-invocation fact, never project state. You do not convert the app. You scope a node.

src/my_app/
├── app.py # create_app() — the composition root
├── controllers/ # unscoped feature code
├── domains/ # top-level domains
├── di/ # app providers (`*_provider.py`)
├── services/
├── infrastructure/ # db, cache, events
├── shared/ # cross-cutting (see below)
└── modules/
├── __init__.py # AuthModule lives here
└── auth/
├── __init__.py # @module AuthModule
├── protocols.py # the contract other modules import
├── provider.py # AuthProvider (register/boot/shutdown)
├── services.py
├── controllers/ # the same components, now module-local
├── domains/ # module-level domains
├── repositories/
└── tests/ # quadkit gen test --module auth

Cross-cutting (src/<app>/shared/<component>/, --module ignored):

audit, errors, features, filters, health, interceptors, mcp, metrics, middleware, providers, schema, schema/dataloaders, search, storage/backends, tenancy, vector/collections

Module-local (src/<app>/<component>/ → src/<app>/modules/<slug>/<component>/):

controllers, domains, services, repositories, commands, queries, events, handlers, consumers, tasks, sagas, pipelines, projections, workflows, webhooks, websocket, clients, notifications, policies, admin/actions, admin/resources

App-level providers live in src/<app>/di/ (*_provider.py). Cross-cutting provider packages still land in shared/providers/.

Project root, never moved: migrations/versions, seeds.

tests/unit follows the node: with --module auth a generated test lands in src/<app>/modules/auth/tests/.

The full generator → path map lives with the CLI: Project layout. If that dump still shows models/ or quadkit gen model, this site wins: generated feature types land in domains/, app providers in di/.


One create_app() serves a flat project and one full of bounded contexts. List the modules this app uses. Controllers are discovered from both the app-root package and modules/ — never listed by hand.

src/my_app/app.py
from quadkit import Application, QuadKitConfig
from quadkit.web import WebModule
from my_app.modules.auth import AuthModule
def create_app(config: QuadKitConfig | None = None) -> Application:
application = Application(name="my-app", config=config)
application.add_modules(
[
AuthModule,
WebModule.configure(
discover=[
"my_app.controllers",
"my_app.modules",
]
),
]
)
return application
app = create_app()

An unscoped controller lives at the app root; a scoped one lives inside its module. Listing them in the composition root would let it wire a controller the module should own.

When you add SQL or an agent, pass DatabaseModule.configure(...) or AgentsModule.configure(...) in the same list, then application.add_providers([...]) for app-root providers — that is how examples/sql-repository and examples/support-agent boot.


quadkit new module auth writes the @module class, a protocol file, and a provider. Other modules import the protocol, never the implementation.

src/my_app/modules/auth/__init__.py
from quadkit.di.module import Module, module
from my_app.modules.auth.provider import AuthProvider
from my_app.modules.auth.protocols import AuthServiceProtocol
@module(
providers=[AuthProvider],
exports=[AuthServiceProtocol],
)
class AuthModule(Module):
"""Authentication — only AuthServiceProtocol is visible to importers."""
ConventionWhy
__init__.py is the module boundaryThe @module class is the public API of the package
protocols.py is the contractOther modules import protocols, never concrete classes
exports=[…] controls visibilityOnly exported types are accessible to importers
The provider stays internalIt registers services; it is not imported by other modules

You can still mix a standalone provider with modules in the same app:

app.add_module(AuthModule) # bounded — exports only
app.add_provider(MetricsProvider()) # standalone — globally visible

FilePurpose
src/<app>/app.pyComposition root. quadkit run / quadkit dev boot <app>.app:app
application.yamlTyped config, loaded by QuadKitConfig
src/<app>/infrastructure/Framework wiring — db, cache, auth, tasks
src/<app>/controllers/Unscoped HTTP controllers, auto-discovered
src/<app>/domains/Top-level domain types (module-local copy lives under modules/<slug>/domains/)
src/<app>/di/App providers (*_provider.py)
src/<app>/shared/Cross-cutting components (quadkit gen error, middleware, …)
src/<app>/modules/Bounded contexts (quadkit new module)
tests/conftest.pyBoots create_app() for pytest