Skip to main content

Module Structure for Spring Boot and .NET Teams

This guide explains PolePosition's module structure for readers who may not know PolePosition or FastAPI yet.

The short version:

PolePosition gives FastAPI projects a familiar enterprise shape without turning FastAPI into Spring Boot or ASP.NET Core. A module is a small feature boundary: routes, schemas, service logic, repository code, and optional database model live together.

Mental Model

If you come from Spring Boot, think of a PolePosition module as a feature package that contains a controller, service, repository, DTOs, and entity.

If you come from ASP.NET Core, think of a PolePosition module as a feature folder with endpoints or a controller, request and response models, service logic, repository code, and an EF Core-style entity.

PolePosition keeps the same idea but uses FastAPI and SQLAlchemy names.

ConceptSpring BootASP.NET CorePolePosition / FastAPI
Web endpoint group@RestControllerController or Minimal API grouprouter.py with APIRouter
Endpoint declaration@GetMapping, @PostMapping[HttpGet], MapGet, MapPost@router.get, @router.post
Request and response typesDTOs or recordsDTOs, records, request modelsschemas.py with Pydantic models
Business logic@Serviceservice classservices/<module>_service.py
Persistence boundary@Repositoryrepository or DbContext wrapperrepository.py
Database modelJPA @EntityEF Core entitymodel.py with SQLAlchemy model
Database migrationsFlyway or LiquibaseEF Core migrationsAlembic migrations
App route compositioncomponent scan plus MVC configProgram.cs route mappingapi/router.py includes module routers

Java and FastAPI Vocabulary

This table is intentionally more detailed for Spring Boot and Java readers.

Java / Spring BootPython / FastAPI / PolePosition
@EntitySQLAlchemy model class in model.py
@Table(name = "...")__tablename__ = "..." in a SQLAlchemy model
JPA field annotations such as @ColumnSQLAlchemy mapped_column(...)
DTO, record, request objectPydantic model in schemas.py
@ValidFastAPI automatically validates Pydantic request models
Bean Validation such as @NotBlank, @Size, @EmailPydantic field types and Field(...) constraints
Validation errorsFastAPI 422 validation responses
Lombok @DataPydantic models and normal Python classes remove most boilerplate
Lombok @BuilderPydantic model construction, .model_validate(...), and .model_copy(...)
@RestControllerrouter.py with APIRouter
@GetMapping, @PostMapping@router.get, @router.post
@Servicemodule-local service class under services/
@Repositoryrepository.py repository class
@Transactionalexplicit SQLAlchemy session, commit, rollback, and transaction handling
application.yml or application.properties.env plus settings.py
Spring profilesAPP_ENV and settings-driven environment behavior
Flyway or Liquibase migrationAlembic revision under migrations/versions/

For example, Java Bean Validation:

public record CustomerCreate(
@NotBlank
@Size(max = 120)
String name
) {}

maps naturally to a Pydantic schema:

from pydantic import BaseModel, Field


class CustomerCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)

When this schema is used as a FastAPI endpoint parameter, FastAPI validates the request body before your service logic runs.

.NET and FastAPI Vocabulary

This table is intentionally more detailed for ASP.NET Core and EF Core readers.

ASP.NET Core / .NETPython / FastAPI / PolePosition
Controller classrouter.py with APIRouter
Minimal API route groupmodule router.py included with a prefix
[HttpGet], [HttpPost], [HttpPatch]@router.get, @router.post, @router.patch
Route attributes such as [Route("api/customers")]include_router(..., prefix="/customers")
Request DTO or command recordPydantic request model in schemas.py
Response DTO or view modelPydantic response model in schemas.py
Data annotations such as [Required], [StringLength], [EmailAddress]Pydantic field types and Field(...) constraints
Model bindingFastAPI parameter and request body parsing
ModelState validationFastAPI automatic validation responses
IServiceCollection registrationdirect imports, dependency functions, and explicit wiring
Service classmodule-local service class under services/
Repository classrepository.py repository class
EF Core entitySQLAlchemy model class in model.py
DbSet<Customer>SQLAlchemy model plus repository queries
DbContextSQLAlchemy Session and session factory
EF Core migrationsAlembic revisions under migrations/versions/
appsettings.json.env plus settings.py
ASP.NET Core environmentsAPP_ENV and settings-driven environment behavior
Middleware pipelineFastAPI middleware in bootstrap/middleware.py
Exception filters or problem details middlewareexception handlers in bootstrap/errors.py

For example, a .NET request record with data annotations:

public sealed record CustomerCreate(
[Required]
[StringLength(120, MinimumLength = 1)]
string Name
);

maps naturally to a Pydantic schema:

from pydantic import BaseModel, Field


class CustomerCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)

ASP.NET Core model binding and validation usually happen before the controller action runs. FastAPI behaves similarly for Pydantic request models: invalid request bodies receive validation responses before your service logic runs.

Generated Project Shape

A generated PolePosition app uses this shape:

src/<package>/
app.py
main.py
run.py
settings.py
auth/
bootstrap/
api/
router.py
db/
base.py
models.py
session.py
modules/
status/

The important folder is modules/. Each domain feature belongs there.

The runtime entrypoints are intentionally split:

  • app.py defines create_app() and wires FastAPI when that factory is called.
  • main.py exposes the ASGI app used by Uvicorn.
  • run.py starts the local process from settings in .env.

This is similar to keeping app composition separate from process hosting in Spring Boot or ASP.NET Core. It also prevents settings and logging from being initialized merely by importing app.py.

Standard Module Shape

When you run:

polepos add module customers

PolePosition creates:

src/<package>/modules/customers/
__init__.py
model.py
repository.py
router.py
schemas.py
services/
__init__.py
customers_service.py
tests/integration/test_customers.py
tests/unit/test_customers_service.py

It also updates:

src/<package>/api/router.py
src/<package>/db/models.py
src/<package>/modules/__init__.py

That means the module is generated, registered with the API router, and wired for Alembic model discovery.

File Responsibilities

router.py

This is closest to a Spring @RestController or an ASP.NET Core controller. It owns HTTP route declarations.

from fastapi import APIRouter

router = APIRouter()


@router.get("/")
def list_customers():
...

In FastAPI, route decorators attach endpoints to an APIRouter. They do not hide the route; they define it directly in normal Python code.

schemas.py

This is closest to DTOs, request models, response models, or records. PolePosition uses Pydantic models here.

from pydantic import BaseModel


class CustomerCreate(BaseModel):
name: str


class CustomerRead(BaseModel):
id: int
name: str

services/<module>_service.py

This is the business workflow boundary. Keep domain decisions here instead of putting all logic directly in the router.

class CustomerService:
def create_customer(self, payload: CustomerCreate):
...

repository.py

This is the persistence boundary. It uses SQLAlchemy sessions and queries.

class CustomerRepository:
def list(self):
...

model.py

This is the SQLAlchemy database model. It is closest to a JPA entity or EF Core entity.

class Customer(Base):
__tablename__ = "customers"

Schema changes should flow through Alembic migrations, not application startup.

How Router Wiring Works

polepos add module customers creates the module router and registers it in src/<package>/api/router.py:

from <package>.modules.customers.router import router as customers_router

api_router.include_router(customers_router, prefix="/customers", tags=["customers"])

This registration happens once per module. The module router owns paths relative to that prefix, so a generated @router.get("/") handler becomes GET /api/v1/customers/ once the app-level API prefix is applied. A different module can also define @router.get("/") because it is included under a different module prefix.

After that, if you add more endpoints inside:

src/<package>/modules/customers/router.py

you do not need to edit the main router again.

For example:

@router.get("/{customer_id}")
def get_customer(customer_id: int):
...


@router.patch("/{customer_id}")
def update_customer(customer_id: int):
...

Those endpoints are automatically part of the already-included customers router.

You only need another main router registration when you create another APIRouter, another module, or a separate router file manually.

How This Differs From Spring Component Scanning

Spring Boot often discovers controllers and services through annotations and component scanning.

PolePosition does not rely on hidden component scanning. It keeps the FastAPI composition explicit:

  • module endpoints use @router.get, @router.post, and similar decorators
  • the module router is included once in api/router.py
  • database models are imported through db/models.py for Alembic metadata

This makes the project easier for humans and coding agents to inspect. The route tree is normal Python code, not hidden framework state.

How This Differs From ASP.NET Core Program.cs

ASP.NET Core often maps controllers or endpoint groups in Program.cs.

PolePosition uses api/router.py for that composition role. It is the central API router file:

src/<package>/api/router.py

The FastAPI app includes that API router in app.py, and each module router is included under it.

The hosted ASGI object lives in main.py as app = create_app(). This keeps app.py as a reusable factory surface for tests and tooling, while run.py remains the normal local entrypoint.

API-Only Modules

If a feature does not need a database model or repository, use:

polepos add module webhooks --api-only

This creates:

src/<package>/modules/webhooks/
__init__.py
router.py
schemas.py
services/
__init__.py
webhooks_service.py

Use this for callbacks, health-adjacent endpoints, transformation endpoints, or thin orchestration surfaces that do not need database tables yet.

AI Prompt Modules

If a feature is an LLM prompt workflow, use:

polepos add module assistant --template ai-prompt

This creates a module with:

orchestrator.py
prompts.py
router.py
schemas.py
services/
__init__.py
assistant_service.py

It also creates shared integrations/llm adapter stubs when missing.

What To Edit After Generation

For a real domain, expect to edit:

  • model.py: database fields and table shape
  • schemas.py: request and response contracts
  • services/<module>_service.py: business rules and workflow orchestration
  • repository.py: queries and persistence behavior
  • router.py: endpoint paths and HTTP behavior
  • generated tests: examples of expected behavior

The generated module is a strong starting point, not the final business system.

What Not To Do

Avoid these patterns:

  • do not put all features in one global services/ folder
  • do keep service classes inside the owning module's services/ package
  • do not create tables during FastAPI startup
  • do not bypass Alembic for schema changes
  • do not manually recreate module boilerplate when polepos add module fits
  • do not remove PolePosition-managed markers unless you intentionally opt out

Lifecycle Flow

Use this flow when growing a REST API:

polepos add module customers
# edit model.py, schemas.py, services/customers_service.py, repository.py, router.py
polepos check
polepos db revision -m "add customers table"
polepos db upgrade
uv run pytest

For coding agents and LLMs, the rule is:

When the user asks for a new domain feature, prefer generating a PolePosition module first, then reshape that module for the real domain.