A NestJS-style, batteries-included web framework for Python, built on top of FastAPI.
Austial gives Python the exact developer experience of NestJS:
decorator-driven modules/controllers/providers, real constructor-based
dependency injection, the full request lifecycle (guards, pipes,
interceptors, exception filters, middleware), a config layer, a database
layer, Terminus-style health checks, a testing module -- and a CLI so you can
scaffold new projects and artifacts the same way nest new/nest generate do.
This repository (austial-py) is the framework itself -- an installable
library, no bundled sample app. To see it in action, scaffold a new project
with the CLI (see Quickstart below); that generated project is
the "hello world" app, kept separate from the framework's own source tree.
FastAPI is a fantastic ASGI toolkit, but it doesn't prescribe an application architecture. Austial is opinionated on top of FastAPI the way Nest is opinionated on top of Express/Fastify: modules own controllers and providers, providers get dependency-injected by type, and the request pipeline (guard → pipe → interceptor → handler → filter) is a first-class concept instead of something you hand-roll with dependencies everywhere.
| NestJS | Austial |
|---|---|
@Module() |
@Module(imports=, controllers=, providers=, exports=) |
@Controller() / @Get() etc. |
@Controller(prefix) / @Get/@Post/@Put/@Patch/@Delete/@Options/@Head |
@Injectable() |
@Injectable(scope=Scope.DEFAULT | Scope.TRANSIENT) |
| Constructor DI | Container resolves __init__ type hints recursively, singleton by default |
NestFactory.create(AppModule) |
AustialFactory.create(AppModule) -> AustialApplication |
app.listen(3000) |
await app.listen(8000) |
CanActivate / @UseGuards |
CanActivate ABC + ExecutionContext, @UseGuards(...) |
NestInterceptor / @UseInterceptors |
NestInterceptor ABC (intercept(context, call_next)) + @UseInterceptors(...) |
PipeTransform / ValidationPipe |
PipeTransform ABC + ValidationPipe |
ExceptionFilter / @Catch |
ExceptionFilter ABC + @Catch(...), default AllExceptionsFilter |
NestMiddleware, MiddlewareConsumer |
NestMiddleware ABC, configure(consumer) on modules |
ConfigModule.forRoot() |
ConfigModule.for_root(env_file=".env") / ConfigService |
TypeOrmModule.forRootAsync |
DatabaseModule.for_root_async(use_factory=, inject=) (SQLAlchemy async) |
@nestjs/terminus |
austial.terminus.HealthCheckService + MemoryHealthIndicator, DatabaseHealthIndicator |
@nestjs/testing |
austial.testing.Test.create_testing_module(...).compile() |
nest new / nest generate |
austial new <name> / austial generate|g module|controller|service|resource <name> |
Nest names files with dots (health.controller.ts) because Node resolves
modules by explicit relative path strings. Python's import a.b instead
resolves a as a package and b as a submodule -- a literal file
health.controller.py isn't importable via a normal import statement. So
Austial uses underscore suffixes instead: health_controller.py,
health_service.py, health_module.py, health_dto.py. The directory
layout still mirrors Nest 1:1 -- one folder per feature under
src/modules/.
Austial is published on PyPI -- install the CLI and scaffold a new project:
uv tool install austial # or: pipx install austial / pip install austial
austial new my-app # scaffold a new project
cd my-app
uv sync
cp .env.example .env
uv run austial serve # http://localhost:8000, auto-reloadaustial new scaffolds a small health-check "hello world" app --
GET /, GET /health, GET /health/protected, /docs (Swagger UI) -- see
Using the CLI below for the full generator reference.
git clone https://github.com/austial/austial-py
cd austial-py
uv sync --all-extras
uv run pytestuv run ruff check . # lint
uv run ruff format . # format
uv run mypy austial tests # type-check
uv run pre-commit install --hook-type pre-commit --hook-type pre-push
uv run pre-commit run --all-files # run every hook once, on demandOnce installed, git commit runs formatting/linting/type-checks automatically,
and git push also runs the test suite -- mirroring a typical Nest project's
husky + lint-staged setup. Projects scaffolded with austial new get the same
.pre-commit-config.yaml out of the box.
austial new scaffolds a brand-new project exactly like nest new:
uv run austial new my-app
cd my-app
uv sync
cp .env.example .env
uv run austial serveNew projects depend on the published
austialpackage from PyPI by default. If you're developing the framework itself, point new projects at your local checkout instead with--link:austial new my-app --link /path/to/austial-py-- this adds an editable[tool.uv.sources]entry souv syncresolvesaustialfrom your local checkout rather than PyPI.
austial generate (alias austial g) adds artifacts to an existing project,
patching src/app_module.py's imports/imports=[...] array automatically --
just like nest g:
uv run austial generate module cats # src/modules/cats/cats_module.py
uv run austial generate controller cats # src/modules/cats/cats_controller.py
uv run austial generate service cats # src/modules/cats/cats_service.py
uv run austial generate resource cats # module + full CRUD controller/service/dto/entity
uv run austial g resource cats # same as above, short aliasMirrors how nestjs/nest splits
its monorepo into independently-published packages/*, rather than one
monolithic library. The root pyproject.toml is a virtual uv
workspace (like
lerna.json + the root package.json's "workspaces" field) -- it has no
[project] of its own and installs nothing directly.
austial-py/
├── pyproject.toml # virtual workspace root: [tool.uv.workspace], shared ruff/mypy/pytest config
├── LICENSE # MIT
├── assets/ # logo.svg (+ logo-dark.svg for dark mode) / icon.svg / apple-icon.png
├── packages/ # every installable distribution; each is `austial.<name>` via
│ │ # a pkgutil-style namespace-package shim -- imports stay
│ │ # `from austial.core import ...` etc, unchanged
│ ├── austial/ # umbrella "austial" dist: re-exports common+core's
│ │ │ # everyday symbols, depends on ALL packages below,
│ │ │ # owns the `austial` console-script entry point
│ │ └── src/austial/__init__.py
│ ├── common/ # "austial-common" -- decorators, guards, interceptors,
│ │ │ # pipes, filters, middleware, exceptions, logger
│ │ └── src/austial/common/
│ ├── core/ # "austial-core" -- metadata/reflector, DI container,
│ │ │ # router builder, AustialFactory / AustialApplication
│ │ └── src/austial/core/
│ ├── config/ # "austial-config" -- ConfigModule / ConfigService
│ │ └── src/austial/config/ # (no nestjs/nest equivalent -- mirrors the separate
│ │ # @nestjs/config repo instead)
│ ├── orm/ # "austial-orm" -- DatabaseModule (SQLAlchemy async)
│ │ └── src/austial/orm/ # (mirrors the separate @nestjs/typeorm repo)
│ ├── terminus/ # "austial-terminus" -- HealthCheckService + indicators,
│ │ ├── test/ # own health_check_service_spec.py, like Nest's
│ │ │ # per-package test/ dirs
│ │ └── src/austial/terminus/ # (mirrors the separate @nestjs/terminus repo)
│ ├── testing/ # "austial-testing" -- Test.create_testing_module(...)
│ │ └── src/austial/testing/
│ └── cli/ # "austial-cli" -- `austial new`/`generate`/`serve`
│ └── src/austial/cli/ # + Jinja2 templates (mirrors the separate
│ # nest-cli repo)
└── integration/ # cross-package e2e tests -- mirrors Nest's top-level
# `integration/` dir (its per-package unit tests live
# in each package's own `test/`)
There's deliberately no src/ at the repo root -- that's what austial new
generates for your project, kept out of the framework's own repo. Test files
use Nest's *.spec.ts naming convention (*_spec.py here); the root
pyproject.toml's [tool.pytest.ini_options] scans packages/ and
integration/ to discover them. Run everything from the repo root with
uv sync --all-packages --group dev.
Every route runs through the same pipeline Nest uses:
guards -> pipes -> interceptors (wrapping) -> handler -> exception filters
from austial import Controller, Get, UseGuards, UseInterceptors
from austial.common.interceptors import TransformInterceptor
from .api_key_guard import ApiKeyGuard
@Controller("cats")
class CatsController:
@Get(":id")
@UseGuards(ApiKeyGuard)
@UseInterceptors(TransformInterceptor())
async def find_one(self, id: int):
...Note path params use Nest's :id syntax (translated to FastAPI's {id}
under the hood), so route strings read exactly like their Nest counterparts.
Every packages/* distribution's version is derived automatically from git
tags via hatch-vcs -- there is no
version to hand-edit in any pyproject.toml anymore. An untagged commit
builds as a .devN+g<sha> version; a commit exactly on a vX.Y.Z tag builds
as the clean release version X.Y.Z. This is what caused the original
0.1.1/0.1.2 PyPI "file already exists" mixups -- manually-bumped,
hand-edited versions drifting out of sync with the tag actually being
published -- so releasing is now just:
git tag -a vX.Y.Z -m "vX.Y.Z - ..."
git push origin vX.Y.ZPushing that tag triggers .github/workflows/publish.yml,
which re-runs the lint/type-check/test gate, builds the sdist + wheel for
every packages/* member once with uv build --all-packages, then publishes
each of the 8 distributions to PyPI via
trusted publishing (no API token
stored as a secret) from its own job in a matrix -- one job per PyPI project,
each minting its own OIDC token, since a trusted-publishing token is scoped
to a single project per mint request and funnelling every project's files
through one job/one token gets every project but one rejected with 403 Invalid API Token: OIDC scoped token is not valid for project '<name>'.
A final job attaches all the built artifacts to a GitHub release.
One-time setup per distribution: austial, austial-common,
austial-core, austial-config, austial-orm, austial-terminus,
austial-testing and austial-cli are each their own PyPI project and each
need a trusted publisher added for austial-py's publish.yml workflow
(environment pypi) -- on the existing austial project's
publishing settings
page for austial itself, or via a
"pending publisher" for the
7 project names that don't exist on PyPI yet.