No description
Find a file
Antoine Pelletier 572489ed5c first commit
2026-07-29 16:47:05 +02:00
db first commit 2026-07-29 16:47:05 +02:00
dev-db first commit 2026-07-29 16:47:05 +02:00
frontend first commit 2026-07-29 16:47:05 +02:00
src first commit 2026-07-29 16:47:05 +02:00
.env first commit 2026-07-29 16:47:05 +02:00
.gitignore first commit 2026-07-29 16:47:05 +02:00
build.rs first commit 2026-07-29 16:47:05 +02:00
Cargo.lock first commit 2026-07-29 16:47:05 +02:00
Cargo.toml first commit 2026-07-29 16:47:05 +02:00
config.example.yml first commit 2026-07-29 16:47:05 +02:00
README.md first commit 2026-07-29 16:47:05 +02:00
rename.sh first commit 2026-07-29 16:47:05 +02:00

App template

Starting point for an AGEPoly web project: a Rust backend (axum + sqlx + aide) serving a Vue 3 frontend (TypeScript + vue-query + shadcn-vue), on PostgreSQL with dbmate migrations.

The template is deliberately almost empty: one table (items), one route (GET /api/items) and one page (home) that displays it. They exist to show how the layers fit together — rename them, or delete them once your own code is in place.

What you get

  • Layered backend (api / core / services), see Backend
  • An OpenAPI document generated from the code, and TypeScript types generated from it: the frontend cannot call an endpoint that does not exist
  • i18n (fr/en) on both sides, localized strings stored as JSONB
  • dev-db/: postgres + adminer in docker for development
  • Tailwind 4 and the shadcn-vue components already vendored in frontend/src/components/ui
  • A single binary in production: the backend serves the built frontend

There is no authentication in this template: add whatever the project needs.

Bootstrap a new project

cp -r template /path/to/my-project && cd /path/to/my-project
git init

# 1. Rename the crate and the database (app-template -> my-project)
./rename.sh my-project "My Project" && rm rename.sh

# 2. Start the development database
cd dev-db && docker compose up -d && cd ..
psql -h localhost -U postgres -c 'CREATE DATABASE my_project'
dbmate up
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql   # optional demo data

# 3. Configure the app
cp config.example.yml config.yml

# 4. Run the backend (port 3000)
cargo run

# 5. Run the frontend (port 5000, proxies /api to the backend)
cd frontend && npm install && npm run dev

Open http://localhost:5000. The api documentation is on http://localhost:3000/api/docs.

config.yml is gitignored: it is where the secrets go. config.example.yml documents every key, keep it up to date. Every value can also come from the environment (APP__POSTGRES__PASSWORD=...), which is how the app is configured in production.

Layout

├── src/                  backend
│   ├── api/              http layer: routes, extractors, OpenAPI
│   ├── core/             business logic, independent of axum and sqlx
│   │   ├── controller/   what the app can do
│   │   ├── models/       domain types
│   │   └── repositories/ traits describing what the core needs from the storage
│   ├── services/         implementations of the repositories (postgres/sqlx)
│   └── utils/            configuration
├── db/
│   ├── migrations/       dbmate migrations
│   ├── schema.sql        dump regenerated by dbmate, do not edit by hand
│   └── seed.sql          development data
├── dev-db/               postgres + adminer for development
└── frontend/
    └── src/
        ├── components/    shared components (ui/ = shadcn-vue)
        ├── views/         one component per route
        ├── router/        route table
        ├── services/      api/ (one file per domain area) + i18n
        ├── lib/api.d.ts   generated from the backend, never edited by hand
        ├── utils/types.ts shorthands over the generated schemas
        └── locales/       fr.yml / en.yml

Backend

Requests flow through three layers, each one only knowing the next:

api (axum handler)  ->  core/controller  ->  core/repositories  ->  services/database
   status codes           business logic         trait                sqlx + postgres
                          domain models

The point of the repository traits is that the core never depends on sqlx: you can add another implementation (a mock in tests, another storage) without touching the logic. Handlers stay thin — extract the controller, call it, map the error to a status code — and their OpenAPI documentation sits right next to them (fn *_docs).

Adding an entity

  1. dbmate n create_things and write the migration, then dbmate up
  2. src/core/models/thing.rs: the domain types
  3. src/core/repositories/things_repository.rs: the trait, added to DatabaseRepository
  4. src/services/database/things.rs: the sqlx implementation
  5. src/core/controller/things.rs: the logic, plus a ThingsControllerError if needed
  6. src/api/things.rs: the handlers and their docs, mounted in src/api/mod.rs
  7. cd frontend && npm run openapi to regenerate the types, then write the service and the view

The Item entity follows exactly these steps: copy it.

Request bodies are best kept separate from the domain models (an api/models.rs holding the ...Api structs and their Into<Domain> impls), so that the public contract does not change every time a domain model does.

sqlx and compilation

query!/query_as! check the sql against a real database at compile time, so the development database must be up and migrated for cargo build to work. DATABASE_URL is read from .env.

To build without a database (CI, docker image), commit the offline data:

cargo install sqlx-cli
cargo sqlx prepare      # writes .sqlx/, commit it

Migrations

dbmate n add_something   # creates db/migrations/<timestamp>_add_something.sql
dbmate up                # applies, and regenerates db/schema.sql
dbmate rollback          # undoes the last migration (write your `migrate:down`!)

Frontend

npm run dev          # dev server on :5000, /api proxied to the backend on :3000
npm run build        # type-check + build into dist/
npm run type-check
npm run lint
npm run format
npm run openapi      # regenerate src/lib/api.d.ts from the running backend

Rules of thumb:

  • views never call fetch: they use a hook from services/api/, which returns a vue-query query or mutation. Caching, loading and error states come for free.
  • mutations update the cache in onSuccess so the ui reacts immediately.
  • texts live in locales/*.yml and are used through $t('key'), never hardcoded.
  • add a shadcn-vue component with npx shadcn-vue@latest add <name>.

Production

cargo build --release, npm run build, then point frontend_dir at the built frontend/dist: the backend serves the static files and falls back on index.html so the vue router keeps working on a page reload. Only one process to deploy.