Skip to content

One project layout. Every project has the same tree, built from the same 48 quadkit gen generators against one canonical generator→path map (quadkit/cli/layout.py), so scaffolding and code generation stay in lockstep.

There is no --structure flag and no [tool.quadkit] structure key. What used to be three project shapes is one shape plus a per-node fact: whether that node has joined a module.

Implementation status (all shipped in this CLI):

  • quadkit new project --template <t> — 6 templates, one layout, generated AGENTS.md
  • quadkit new module <name> — bounded-context creation + registry
  • render_project_metadata(ProjectMetadataContext) — deterministic, side-effect-free project agent-file renderer shared with downstream builders
  • quadkit gen <generator> <name> [--module <feature>] — path resolution
  • Canonical map enforced by the dev gate (dev/checks/generator_output.py) and the unit suite (tests/unit/test_layout.py)
  • SDK renames src/graphql → src/schema (+ schema/dataloaders) and src/collections → src/vector/collections

Two questions decide every path.

  1. Is this component cross-cutting? Cross-cutting components are one per application by definition (errors, middleware, providers, health…). They land in src/<app>/shared/<component>/ and stay there whatever module the node belongs to.
  2. Is this node in a module? A module-local component lands in src/<app>/<component>/ while the node is unscoped, and moves to src/<app>/modules/<slug>/<component>/ the moment it joins a module.

The composition root is always src/<app>/app.py, and the ASGI target is always <app>.app:app.

Unscoped feature code sits at the app package root, not in shared/, so shared/ keeps exactly one meaning: cross-cutting. A project that never draws a module simply never has a modules/<slug>/ directory.


my-platform/
├── application.yaml
├── application.production.yaml # optional profile overlays
├── pyproject.toml # [tool.quadkit] module = "my_platform.app:app"
├── README.md
├── AGENTS.md # project-specific coding-agent guidance
├── .env.example
├── migrations/versions/ # quadkit gen migration
├── seeds/ # hand-written seeders; `quadkit db seed`
│ # discovers generated seeders through the
│ # layout and looks here second (legacy)
├── src/
│ └── my_platform/
│ ├── __init__.py
│ ├── app.py # create_app() — the composition root
│ ├── py.typed
│ ├── controllers/ # unscoped feature code: app-root components
│ ├── domains/ # appear as they are generated
│ ├── services/
│ ├── infrastructure/ # framework wiring: db, cache, events, monitoring
│ │ └── __init__.py # infrastructure_modules()
│ ├── shared/ # cross-cutting components (see §3)
│ │ ├── di/ # quadkit gen provider
│ │ ├── errors/
│ │ ├── filters/
│ │ ├── middleware/
│ │ ├── interceptors/
│ │ ├── metrics/
│ │ ├── health/
│ │ ├── tenancy/
│ │ ├── features/
│ │ ├── storage/backends/
│ │ ├── schema/dataloaders/
│ │ ├── vector/collections/
│ │ └── mcp/
│ └── modules/
│ ├── __init__.py # MODULES registry
│ ├── auth/
│ │ ├── __init__.py # @module AuthModule(controllers=[…], exports=[…])
│ │ ├── protocols.py # the module contract
│ │ ├── provider.py # AuthProvider (register/boot/shutdown)
│ │ ├── services.py
│ │ ├── controllers/ # the same components, now module-local
│ │ ├── domains/
│ │ ├── repositories/
│ │ └── tests/ # quadkit gen test --module auth
│ └── billing/
│ └── …same shape…
└── tests/
├── conftest.py # boots create_app()
├── test_app.py
└── unit/ # quadkit gen test (unscoped)

A fresh project ships no sample module: modules/__init__.py exports an empty MODULES, and quadkit new module <name> fills it in.


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

auth, contracts, di, errors, features, filters, guards, health, interceptors, mcp, metrics, middleware, providers, schema, schema/dataloaders, storage/backends, tenancy, vector/collections

providers is retained as a resolution row only: no generator writes there (it was superseded by di, which is where quadkit gen provider now defaults), and the scaffold does not pre-create it — a project generated into shared/providers/ before that change still resolves. auth and contracts likewise carry no generator yet; the rows are the map’s answer for where those packages live.

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

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

audit, search and seeders are module-local by per-entity attachment: an audit log is written for one entity’s table, a search index indexes one entity’s fields, a seeder seeds exactly one entity — each belongs wherever that entity belongs. (Earlier drafts of this document listed all three as cross-cutting; the canonical map never agreed, and the map is the authority.)

One declared exception to import direction: shared/di is composition. Registration providers there bind the whole application together, so they may import module internals by name — that is what assembling an app means. Nothing else under shared/ may import from modules/; modules import shared/, never each other (cross-module traffic goes through the exported protocols, bound in the owning module’s provider.py).

Project root, never moved: migrations/versions (one database, one history — migrations are app-global even though entities are module-owned), seeds (a hand-written legacy collection — generated seeders go to the entity’s own importable seeders package, and quadkit db seed discovers them through the layout, looking here second). tests/unit is the exception that follows its node: with --module auth a generated unit test lands in src/<app>/modules/auth/tests/. Whole-app tests that boot create_app() live at the project-root tests/ wherever they came from — they test the composition, not a context.


Paths below are the declared defaults in the generator definitions; the resolver rewrites them per §1. src/controllers therefore means src/<app>/controllers unscoped and src/<app>/modules/<m>/controllers scoped, while src/errors always means src/<app>/shared/errors.

Generator(s)Declared directory
controllersrc/controllers
domainsrc/domains
servicesrc/services
repository, cache_repo, document_reposrc/repositories
providersrc/di
querysrc/queries
errorsrc/errors
event / event_handlersrc/events / src/handlers
commandsrc/commands
consumer (message_consumer)src/consumers
tasksrc/tasks
saga, saga_stepsrc/sagas
pipelinesrc/pipelines
projectionsrc/projections
workflow_defsrc/workflows
middlewaresrc/middleware
interceptorsrc/interceptors
filter, exception_filtersrc/filters
guard, auth_guardsrc/guards
policy (auth_policy)src/policies
healthsrc/health
metricsrc/metrics
webhooksrc/webhooks
websocketsrc/websocket
api_clientsrc/clients
notification_templatesrc/notifications
feature_flagsrc/features
tenant_resolversrc/tenancy
search_indexsrc/search
storage_driversrc/storage/backends
mcp-controller, mcp-serversrc/mcp
admin_action / admin_resourcesrc/admin/actions / src/admin/resources
auditedsrc/audit
graphql / dataloadersrc/schema / src/schema/dataloaders
vector_collectionsrc/vector/collections
(special) resourcesrc — writes src/<app>/<name>_resource.py, or into the module
migrationmigrations/versions
seedersrc/seeders (module-local per entity; --output seeds still resolves)
testtests/unit

Two default dirs were renamed because they shadowed real modules on sys.path: src/graphql → src/schema (shadowed graphql, used by strawberry) and src/collections → src/vector/collections (shadowed the stdlib collections). Because every component now lives under the app package, importing src/<app>/ can never shadow stdlib or site-packages — the layout is import-safe by construction.


def create_app(config: QuadkitConfig | None = None) -> Application:
application = Application(name="my-platform", config=config)
application.add_modules(
[
*infrastructure_modules(), # db, cache, events, monitoring
*MODULES, # the bounded contexts
WebModule.configure(
discover=[
"my_platform.controllers",
"my_platform.modules",
]
),
]
)
return application
app = create_app()

Controllers are discovered, never registered by hand — in both roots, because an unscoped controller lives at the app root and a scoped one lives inside its module. Listing them explicitly would let the composition root wire a controller the module should own.


Terminal window
quadkit new project my-app
quadkit new module auth # adds src/my_app/modules/auth/
quadkit gen controller users # src/my_app/controllers/…
quadkit gen controller users --module auth # src/my_app/modules/auth/controllers/…
quadkit gen error not_found # src/my_app/shared/errors/… (module ignored)

--module is a per-invocation fact, never project state. Nothing has to be migrated, converted or “switched”: a project grows a bounded context by adding one, and the components that move are exactly the ones scoped into it.

  1. One canonical map. quadkit/cli/layout.py holds a single component → (directory, cross-cutting?) table used by quadkit gen (path resolution), quadkit new project (scaffold dirs) and the alignment gate (dev/checks/generator_output.py).
  2. Same files everywhere. application.yaml, pyproject.toml, README.md, .env.example, tests/conftest.py and tests/test_app.py come from the one render_project().
  3. Runtime parity. [tool.quadkit] module always names <app>.app:app, so quadkit dev / quadkit run boot the object the composition root exposes rather than a re-export of it.
  4. Import safety. No generated package name may collide with stdlib or installed site-packages — enforced by the alignment gate.
  5. Versioned contract. The map carries LAYOUT_VERSION (bumped when a change would move a file in an existing project), and scaffolds stamp it into [tool.quadkit] layout_version. A project records which map produced it, so a regeneration under a newer map is a detectable event instead of files silently moving.