Skip to content

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.

Create the directory and minimum files:

Ventana de terminal
mkdir backend/app/modules/contracts
touch backend/app/modules/contracts/__init__.py
touch backend/app/modules/contracts/api.py
touch backend/app/modules/contracts/models.py
touch backend/app/modules/contracts/schemas.py
touch backend/app/modules/contracts/service.py
from __future__ import annotations
from sqlalchemy import String
from 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.

from uuid import UUID
from 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 = True
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.contracts.models import Contract
from 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 contract
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth.dependencies import get_current_user
from app.core.auth.models import User
from app.core.database import get_db
from app.modules.contracts.schemas import ContractCreate, ContractResponse, ContractUpdate
from 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 contract

Important: the APIRouter prefix must be ''. The module_loader will mount the module at /api/contracts.

Generate and apply the migration:

Ventana de terminal
cd backend
alembic revision --autogenerate -m "add contracts table"
alembic upgrade head

Create the view:

Ventana de terminal
mkdir frontend/src/modules/contracts
touch frontend/src/modules/contracts/ContractList.vue

Add the route in frontend/src/router/index.ts inside MainLayout:

{
path: 'contracts',
name: 'contracts',
component: () => import('@/modules/contracts/ContractList.vue'),
meta: { module: 'contracts' },
},

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.data

Never include /api twice (apiClient.get('/api/contracts') would duplicate the prefix).

Because the new view is a child of MainLayout, you must create:

  1. 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' },
},
]
  1. Registry: add the entry in frontend/src/core/tours/registry.ts:
{ routeName: 'contracts', labelKey: 'tour.idx.contracts', module: 'contracts', icon: 'pi pi-file' }
  1. Guide: add a section in frontend/src/core/guide/guide-content.ts.

  2. i18n: add keys in frontend/src/locales/es.json and en.json:

{
"tour": {
"contracts": {
"s1": { "title": "Contratos", "desc": "Gestiona tus contratos activos." }
}
},
"guide": {
"contracts": {
"title": "Contratos",
"description": "Crea y gestiona contratos con clientes y proveedores."
}
}
}
  1. Add data-tour="contracts.header", data-tour="contracts.new-button", etc. attributes in the view. Tours never use raw CSS selectors.

Before considering the module done:

  1. Start the backend and check that Swagger lists /api/contracts.
  2. Start the frontend and navigate to /contracts.
  3. Run vue-tsc --noEmit in frontend/.
  4. Check there are no missing-tour warnings in the browser console.
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.