Create a module
This guide explains how to create a new module from scratch following the project’s real conventions.
Let’s assume we want to add a module called contracts.
1. Backend: basic structure
Section titled “1. Backend: basic structure”Create the directory and minimum files:
mkdir backend/app/modules/contractstouch backend/app/modules/contracts/__init__.pytouch backend/app/modules/contracts/api.pytouch backend/app/modules/contracts/models.pytouch backend/app/modules/contracts/schemas.pytouch backend/app/modules/contracts/service.py2. SQLAlchemy model (models.py)
Section titled “2. SQLAlchemy model (models.py)”from __future__ import annotations
from sqlalchemy import Stringfrom sqlalchemy.orm import Mapped, mapped_column
from app.core.models import BaseModel
class Contract(BaseModel): __tablename__ = "contracts"
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) name: Mapped[str] = mapped_column(String(200), nullable=False) is_active: Mapped[bool] = mapped_column(default=True, nullable=False)BaseModel already provides id (UUID), created_at and updated_at.
3. Pydantic schemas (schemas.py)
Section titled “3. Pydantic schemas (schemas.py)”from uuid import UUIDfrom pydantic import BaseModel
class ContractBase(BaseModel): code: str name: str is_active: bool = True
class ContractCreate(ContractBase): pass
class ContractUpdate(BaseModel): code: str | None = None name: str | None = None is_active: bool | None = None
class ContractResponse(ContractBase): id: UUID
class Config: from_attributes = True4. Service (service.py)
Section titled “4. Service (service.py)”from sqlalchemy.ext.asyncio import AsyncSessionfrom app.modules.contracts.models import Contractfrom app.modules.contracts.schemas import ContractCreate, ContractUpdate
class ContractService: def __init__(self, db: AsyncSession): self.db = db
async def create(self, body: ContractCreate) -> Contract: contract = Contract(**body.model_dump()) self.db.add(contract) await self.db.flush() return contract5. API (api.py)
Section titled “5. API (api.py)”from uuid import UUIDfrom fastapi import APIRouter, Dependsfrom sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth.dependencies import get_current_userfrom app.core.auth.models import Userfrom app.core.database import get_dbfrom app.modules.contracts.schemas import ContractCreate, ContractResponse, ContractUpdatefrom app.modules.contracts.service import ContractService
router = APIRouter(prefix="", tags=["contracts"])
@router.get("", response_model=dict)async def list_contracts( skip: int = 0, limit: int = 50, db: AsyncSession = Depends(get_db), _current_user: User = Depends(get_current_user),): service = ContractService(db) items, total = await service.list(skip=skip, limit=limit) return { "data": [ContractResponse.model_validate(c) for c in items], "total": total, "skip": skip, "limit": limit, }
@router.post("", response_model=ContractResponse, status_code=201)async def create_contract( body: ContractCreate, db: AsyncSession = Depends(get_db), _current_user: User = Depends(get_current_user),): service = ContractService(db) contract = await service.create(body) await db.commit() return contractImportant: the APIRouter prefix must be ''. The module_loader will mount the module at /api/contracts.
6. Alembic migration
Section titled “6. Alembic migration”Generate and apply the migration:
cd backendalembic revision --autogenerate -m "add contracts table"alembic upgrade head7. Frontend: view and route
Section titled “7. Frontend: view and route”Create the view:
mkdir frontend/src/modules/contractstouch frontend/src/modules/contracts/ContractList.vueAdd the route in frontend/src/router/index.ts inside MainLayout:
{ path: 'contracts', name: 'contracts', component: () => import('@/modules/contracts/ContractList.vue'), meta: { module: 'contracts' },},8. Frontend: API call
Section titled “8. Frontend: API call”From the view use apiClient (baseURL /api):
import apiClient from '@/core/api/client'
const response = await apiClient.get('/contracts?limit=50')const contracts = response.data.dataNever include /api twice (apiClient.get('/api/contracts') would duplicate the prefix).
9. Mandatory tour, guide and i18n
Section titled “9. Mandatory tour, guide and i18n”Because the new view is a child of MainLayout, you must create:
- Tour:
frontend/src/core/tours/contracts.ts
import type { TourStep } from '@/core/types/tour'
export const contractsTour: TourStep[] = [ { element: '[data-tour="contracts.header"]', popover: { titleKey: 'tour.contracts.s1.title', descKey: 'tour.contracts.s1.desc', side: 'bottom', align: 'start' }, },]- Registry: add the entry in
frontend/src/core/tours/registry.ts:
{ routeName: 'contracts', labelKey: 'tour.idx.contracts', module: 'contracts', icon: 'pi pi-file' }-
Guide: add a section in
frontend/src/core/guide/guide-content.ts. -
i18n: add keys in
frontend/src/locales/es.jsonanden.json:
{ "tour": { "contracts": { "s1": { "title": "Contratos", "desc": "Gestiona tus contratos activos." } } }, "guide": { "contracts": { "title": "Contratos", "description": "Crea y gestiona contratos con clientes y proveedores." } }}- Add
data-tour="contracts.header",data-tour="contracts.new-button", etc. attributes in the view. Tours never use raw CSS selectors.
10. Verification
Section titled “10. Verification”Before considering the module done:
- Start the backend and check that Swagger lists
/api/contracts. - Start the frontend and navigate to
/contracts. - Run
vue-tsc --noEmitinfrontend/. - Check there are no missing-tour warnings in the browser console.
Quick conventions
Section titled “Quick conventions”| Rule | Correct | Incorrect |
|---|---|---|
| Backend router prefix | prefix="" |
prefix="/contracts" |
| Frontend call | apiClient.get('/contracts') |
apiClient.get('/api/contracts') |
| PrimeVue component | import Select from 'primevue/select' |
import Dropdown from 'primevue/dropdown' |
| Toggle | ToggleSwitch |
InputSwitch |
| Tabs | Tabs/TabList/Tab |
TabView |
With these steps the module is integrated, documented and ready to use.