Skip to content

How I built QuadKit — the framework, the docs, and the AI tooling

I built QuadKit alone. No team, no funding, no timeline. Just a problem I kept hitting — Python web frameworks don’t think in protocols, don’t enforce boundaries, and don’t give AI coding agents the machine-readable context they need to generate correct code.

This post explains the architecture decisions, the agent-augmented workflow, and why the project looks the way it does.

Every Python web app I built ended up the same way: services importing from each other, Result types leaking into controllers, and no way to swap implementations without touching three files. FastAPI made it worse — Pydantic models double as domain types, so your API layer and business logic fuse together.

Pick any two Python web projects and you’ll find different patterns for the same thing. Not minor style differences — structural inconsistencies that break the mental model:

  • Error handling. One module raises ValueError("user not found"), another returns None and checks at the call site, another wraps in a custom exception with a string message. There’s no contract for what a failed operation looks like, so every caller invents its own error-checking pattern.

  • Dependency wiring. One service takes its dependencies as __init__ arguments, another uses @inject decorators, another pulls from a global settings object. When you need to swap a mock in tests, you have to figure out which pattern each service used.

  • Configuration access. One module reads os.environ["DB_URL"], another uses pydantic-settings, another takes a config dict passed through three layers. There’s no single place to see what the app needs or validate that it’s complete.

  • Project layout. models/ vs domains/ vs schemas/ vs types/. services/ vs handlers/ vs use_cases/. controllers/ vs routes/ vs views/. Every project invents its own directory grammar, and contributors have to learn it from scratch.

  • API response shape. One endpoint returns {"data": [...], "meta": {...}}, another returns a flat list, another returns {"items": [...], "total": 5}. Clients parse three different shapes from the same API.

  • Type hints. One file uses Optional[str], another uses str | None, another uses Any because “it works anyway.” Protocols vs ABCs. TypeVar vs generic. The type system is there but used inconsistently, so static analysis catches half the problems. These aren’t cosmetic. They compound:

  • Onboarding takes longer. New contributors learn project-specific conventions instead of transferable skills. The patterns don’t generalize — they’re local trivia.

  • Code review becomes subjective. “This isn’t how we do it here” replaces objective rules. Without enforceable patterns, style guides are suggestions.

  • AI agents generate wrong code. An agent trained on one project’s patterns will produce inconsistent output in another. The agent doesn’t know which convention applies.

  • Refactoring is risky. Without enforced boundaries, changing one module silently breaks three others. The dependency graph is implicit and unverifiable.

QuadKit solves this by making the patterns enforceable, not aspirational. The import linter doesn’t care about your opinion — it checks the rule. The contracts package doesn’t suggest protocols — it requires them. The error model doesn’t offer a choice — it returns Result[T, E].

I wanted:

  • Contracts first. Protocols live in a zero-dependency package. Everything else depends on them.
  • Typed errors. Result[T, E] instead of exceptions for domain failures. 240+ error codes, one pattern.
  • Import linting. A hard CI gate that blocks cross-extension imports. Not a convention — a rule.
  • Agent-native docs. /llms.txt, /agents.md, /SKILL.md — files that coding agents load directly, not scraped from HTML.
quadkit-contracts Zero deps. Protocols, types, exceptions only.
↑
quadkit Depends ONLY on quadkit-contracts.
↑
quadkit-* Never import each other.

This is the whole design. If two packages need the same type, it lives in quadkit-contracts. No exceptions. An import linter enforces it — a cross-extension import that type-checks still fails CI.

Why? Because every violation I allowed in past projects became a maintenance tax. The linter makes it impossible to accumulate.

Most Python DI frameworks use decorators or globals. I built a provider-based system:

  1. Providers register bindings (register) and resolve them (boot). Two phases, never mixed.
  2. Modules group related providers. WebModule, CacheModule, TaskModule.
  3. The container is a plain dictionary under the hood. No magic, no thread-local, no singleton pattern.
from quadkit.di.provider import Provider
from quadkit.contracts.core import ProviderPriority
from quadkit.contracts.core.di import ContainerRegistrarProtocol, BootContainerProtocol
class BillingProvider(Provider):
name = "billing"
priority = ProviderPriority.APPLICATION
async def register(self, container: ContainerRegistrarProtocol) -> None:
container.singleton(PaymentGateway, StripeGateway(cfg.stripe_key))
container.singleton(InvoiceRepository, InvoiceRepository(cfg.db_url))
async def boot(self, container: BootContainerProtocol) -> None:
gateway = await container.resolve(PaymentGateway)
repo = await container.resolve(InvoiceRepository)
container.singleton(PaymentService, PaymentService(gateway, repo))

This is testable. You can swap providers in tests without patching globals.

I built QuadKit with AI coding agents — Claude Code, OpenCode, Cursor. Not as a gimmick, but because solo development means every hour counts.

The workflow:

  1. I write the core logic and tests.
  2. I delegate boilerplate (controllers, services, error codes) to agents.
  3. Agents load /llms.txt and /agents.md to understand the hierarchy, then generate code that follows the rules.
  4. The import linter catches any violations. The type checker catches the rest.

The key insight: agents need stable, machine-readable context. Not blog posts, not READMEs scattered across repos. One file that lists every URL, every rule, every package. That’s /llms.txt.

This site (oridecon.dev) is built with Astro + Starlight. It serves two audiences:

  • Humans — guides, examples, the framework landing page.
  • Agents — /llms.txt, /agents.md, /SKILL.md, per-package indexes at /llms/<package>.txt.

The same content, two consumption paths. The agent files are generated from the same source as the human docs, so they never drift.

  • 590+ protocols across the core and extensions.
  • 240+ error codes with the QK_ERR_* prefix pattern.
  • 5 published packages: quadkit, quadkit-contracts, quadkit-web, quadkit-cli, quadkit-testing.
  • 80%+ test coverage as a CI gate.
  • 40+ in-house packages powering three MVP projects.
  • Apache 2.0 — every line is public.

The framework is at v0.0.4 alpha. The architecture is stable. The next milestone is v0.1.0 — the first release with a stable public API, full test coverage across all packages, and production-ready docs. After that, v0.2.0 adds the extensions that power real workloads: AI, events, workflows, and caching.

If you’re building AI backends and want to try the framework, start with the examples or read the agent playbook.