Who am I

Le couteau suisse à l'heure de l'IA

Mes guidelines de développement Go et Python - un retour d'expérience

EN REVIEW

10 ans de freelance en tant que tech lead, toujours le même constat à l'arrivée : pas de conventions. Voici les guidelines Go et Python que j'ai construites au fil des missions.

7 February 2025
pythongolangdevtoolsci-cddockerfastapi

En plus de 10 ans de freelance, j’ai enchaîné les rôles de tech lead, team lead, lead infra. Des contextes très différents - startup, scale-up, grand groupe. Et le constat était presque toujours le même à l’arrivée : pas de conventions de code, pas de structure de projet commune, chaque dev faisait à sa sauce.

À chaque mission, je finissais par écrire des guidelines. Comment nommer les fichiers, comment structurer les tests, quel linter utiliser, comment builder les images Docker. Ces guidelines ont évolué au fil des missions, des erreurs et des contextes.

Celles que je partage ici sont le résultat de cette accumulation. Je les utilise sur mes projets perso Go et Python, mais elles viennent de situations bien réelles en équipe.

Le socle commun : les mêmes outils partout

Avant de parler langage, il y a un socle d’outils que j’utilise sur chaque projet, qu’il soit en Go ou en Python.



Task (go-task) remplace Make. La syntaxe YAML est plus lisible, et je peux partager le même squelette de Taskfile.yml entre projets. Chaque projet expose les mêmes commandes : task run-dev, task test, task lint, task ci. Un nouveau dev sur le projet tape task --list et il voit tout ce qu’il peut faire.



Pre-commit bloque les erreurs avant qu’elles arrivent dans Git. Je n’y touche plus, ce sont toujours les mêmes hooks de base : validation YAML/JSON, détection de secrets avec detect-secrets, vérification des GitHub Actions avec actionlint, et conventional commits via commitizen.

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    hooks:
      - id: check-yaml
      - id: check-json
      - id: check-merge-conflict
      - id: check-added-large-files
        args: ['--maxkb=500']
  - repo: https://github.com/Yelp/detect-secrets
    hooks:
      - id: detect-secrets
  - repo: https://github.com/commitizen-tools/commitizen
    hooks:
      - id: commitizen
        stages: [commit-msg]



Conventional commits (feat(scope): message) me permet de comprendre l’historique d’un repo en 30 secondes. Le hook commitizen refuse les commits qui ne respectent pas le format. feat pour une feature, fix pour un bug, chore pour la maintenance.

Le vrai gain c’est le versioning automatique. feat = MINOR, fix = PATCH, BREAKING CHANGE = MAJOR. Un cz bump génère le changelog et bumpe la version tout seul :

## 1.4.0 (2025-01-15)
### feat
- **auth**: add OIDC provider support
- **api**: add batch export endpoint
### fix
- **db**: fix connection pool leak on timeout

Ça remplace les changelogs écrits à la main que personne ne maintient après la v0.3.



OpenCommit (oco) génère les messages de commit avec un LLM. Je l’utilise au quotidien : je stage mes changements, je tape oco et il me propose un message au format conventional commits basé sur le diff. C’est un gain de temps sur les commits de feature ou de refacto où écrire le message prend parfois plus de temps que le changement lui-même.

npm i -g opencommit
oco config set OCO_AI_PROVIDER=anthropic
oco config set OCO_MODEL=claude-sonnet-4-5-20250929

En pratique ça donne :

git add .
oco
# → feat(api): add batch export endpoint with pagination support
# Do you want to use this commit message? (Y/n)

Le message est au format conventional commits, donc il passe le hook commitizen sans problème. Et comme c’est basé sur le diff réel, le message est souvent plus précis que ce que j’aurais écrit à la main à 23h un vendredi.

On peut aussi l’installer comme hook prepare-commit-msg pour qu’il s’intègre directement dans le workflow git classique :

oco hook set
# Maintenant un simple "git commit" propose un message généré

Le .opencommitignore fonctionne comme un .gitignore - j’y mets les fichiers volumineux ou les artefacts pour ne pas envoyer de bruit au LLM.



direnv charge les variables d’environnement automatiquement quand j’entre dans un dossier projet. Plus de “j’ai oublié de sourcer le .env”.

# .envrc
dotenv
layout python python3.12
export APP_ENV=development

En Python, layout python crée et active le virtualenv automatiquement. En Go, je n’ai besoin que du dotenv pour charger les variables d’env. L’avantage c’est que quand je cd dans le projet, tout est prêt - et quand j’en sors, tout est désactivé.



Mise gère les versions de tous mes outils de dev. Avant, j’avais pyenv pour Python, nvm pour Node, goenv pour Go - chaque runtime avait son propre gestionnaire de versions. Mise remplace tout ça avec un seul fichier .mise.toml à la racine du projet.

# .mise.toml
[tools]
python = "3.12"
go = "1.22"
node = "22"
"npm:prettier" = "3"
"pipx:pre-commit" = "latest"

Quand un dev clone le repo et lance mise install, il récupère exactement les mêmes versions que le reste de l’équipe. Plus de “moi je suis en Python 3.11 et ça marche” alors que le projet est en 3.12. Mise détecte le .mise.toml et active automatiquement les bonnes versions quand on entre dans le dossier.

Ce qui est pratique c’est que Mise gère aussi les variables d’environnement et les tâches, un peu comme direnv et Task. Personnellement je garde les trois séparés - direnv pour les env vars, Task pour les commandes, Mise pour les versions - parce que chaque outil fait son job et c’est plus lisible. Mais sur un projet simple, Mise peut tout centraliser dans un seul fichier :

# Mise peut aussi gérer les env vars et les tâches
[env]
APP_ENV = "development"
_.file = ".env"

[tasks.test]
run = "uv run pytest"
description = "Run tests"

L’autre avantage c’est en CI. Le .mise.toml est commité dans le repo, donc le pipeline installe exactement les mêmes versions qu’en local. Un mise install dans le CI et c’est réglé.

Python : les choix de librairies

Côté Python, je suis sur Python 3.12 et j’ai une stack assez arrêtée.

Pour le web c’est FastAPI + uvicorn. L’ORM c’est SQLModel qui combine SQLAlchemy et Pydantic - un seul modèle pour la base et la validation. Les migrations passent par Alembic. La config c’est pydantic-settings, les appels HTTP c’est httpx en async.

Pour le logging j’utilise loguru plutôt que le module logging standard. C’est plus simple à configurer et le formatage est propre par défaut. En dev, icecream pour le debug rapide - ic(variable) au lieu de print(f"variable={variable}").

Côté données, polars remplace progressivement pandas sur mes projets. C’est plus rapide et l’API est plus cohérente. Pour l’Excel, openpyxl.

Pour la qualité du code : Ruff pour le lint et le formatage, mypy pour le type checking, pytest pour les tests, Playwright pour le E2E navigateur. J’utilise aussi vulture et deadcode pour détecter le code mort - c’est le genre de truc qui s’accumule vite.

Ruff et uv ont tout simplifié

Ce qui a vraiment simplifié mon setup Python, c’est le duo Ruff + uv.

Ruff remplace black, isort, flake8 et pyupgrade en un seul outil. Un seul binaire, une seule config dans pyproject.toml.

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP"]
ignore = ["E501", "B008"]

Les règles B (bugbear) et C4 (comprehensions) attrapent des bugs subtils que flake8 seul ne voyait pas. UP (pyupgrade) modernise automatiquement la syntaxe vers Python 3.12.

uv est mon gestionnaire de paquets. uv sync installe les dépendances depuis le lockfile, uv run pytest exécute dans le bon environnement virtuel. Le fichier uv.lock est commité - plus de “ça marche sur ma machine”.

uv sync                # Installer les dépendances
uv add httpx           # Ajouter une dépendance
uv add --dev pytest    # Dépendance dev
uv run pytest          # Exécuter dans le venv

Conventions de nommage Python

Le nommage c’est le truc le plus simple à poser et ça change beaucoup la lisibilité.

Element Convention Exemple
Module / fichier snake_case.py transaction_repository.py
Classe PascalCase TransactionRepository
Fonction / méthode snake_case find_by_email()
Variable snake_case api_key, total_revenue
Constante UPPER_SNAKE DATABASE_URL
Méthode privée _prefixed _validate_required_env_vars()
Test test_<action>_should_<result>_when_<condition> test_login_should_return_token_when_valid_creds

Le nommage des tests est verbeux, je sais. Mais quand un test échoue en CI, je sais immédiatement ce qui a cassé sans ouvrir le fichier.

L’architecture en couches

Mes projets FastAPI suivent tous la même structure. Quatre couches, toujours dans le même ordre :

API Layer (FastAPI Routers)       src/api/rest/
         |
Service Layer (Business Logic)    src/services/<domain>/
         |
Repository Layer (Data Access)    src/repositories/
         |
Models (SQLModel + Pydantic)      src/models/<domain>/

Les routers FastAPI ne font que de la validation Pydantic et de l’injection de dépendances. La logique métier vit dans les services. Les repositories encapsulent les requêtes SQLModel. Les modèles sont organisés par domaine.

@router.get("/transactions")
async def list_transactions(db: DatabaseManager = Depends(get_db)):
    repo = TransactionRepository(db.get_session())
    return repo.find_all()

Le router ne sait pas comment les transactions sont stockées. Il délègue au repository. Si je change de base de données demain, je touche le repository, pas l’API.

Go : les choix de librairies et l’outillage

Côté Go, ma stack est centrée autour de la stdlib et de quelques librairies bien établies.

Pour le HTTP c’est gorilla/mux comme routeur. Le JWT c’est golang-jwt/jwt/v5. Le logging passe par logrus en structuré. La config c’est du YAML + variables d’env. Côté base de données, go-pg ou go-sqlite3 selon le besoin. Pour le cache, go-redis/v9.

L’authorization c’est casbin en ABAC. L’OIDC passe par coreos/go-oidc/v3. Pour la télémétrie, OpenTelemetry + Prometheus client. Les tests utilisent le framework natif testing - pas besoin de plus en Go.

Air et golangci-lint

Air c’est le hot-reload pour Go. Je lance air au lieu de go run, et à chaque modification d’un fichier .go le binaire est recompilé automatiquement. La config est simple :

# .air.toml
[build]
  cmd = "go build -o ./tmp/main ./cmd/server/main.go"
  bin = "./tmp/main"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  include_ext = ["go", "tpl", "tmpl", "html"]
  exclude_regex = ["_test.go"]
[misc]
  clean_on_exit = true

golangci-lint agrège une vingtaine de linters en une passe. J’active gosec pour la sécurité, gocyclo avec un max de 15 pour la complexité, errcheck pour ne jamais ignorer une erreur, et dupl pour détecter le code dupliqué. Le go mod tidy est aussi dans les hooks pre-commit - ça nettoie les dépendances inutilisées à chaque commit.

# pre-commit hooks Go
- repo: local
  hooks:
    - id: golangci-lint
      entry: bash -c 'cd src && golangci-lint run --timeout 5m'
      language: system
      types: [go]
    - id: go-mod-tidy
      entry: bash -c 'cd src && go mod tidy'
      language: system
      types: [go]

Conventions de nommage Go

Element Convention Exemple
Package lowercase userauth, middleware
Fichier snake_case.go rsa_key_manager.go
Type exporté PascalCase JWTService, TokenInfo
Interface Verbe + suffixe TokenValidator, UserRepository
Variable camelCase jwtService, keyManager
Test Test<Type>_<Method>_<Cas> TestUserHandlers_Login_Success

Architecture Ports & Adapters

En Go, j’utilise le pattern Ports & Adapters. Les handlers acceptent des interfaces, jamais des implémentations concrètes. Ça rend le code testable et découplé.

type UserHandlers struct {
    users  interfaces.UserRepository  // Port
    tokens interfaces.TokenService    // Port
    logger *logrus.Logger
}

func NewUserHandlers(
    users interfaces.UserRepository,
    tokens interfaces.TokenService,
    logger *logrus.Logger,
) *UserHandlers {
    return &UserHandlers{users: users, tokens: tokens, logger: logger}
}

Les interfaces sont définies dans pkg/interfaces/ et composées par segregation :

type TokenGenerator interface { GenerateToken(req *SignRequest) (string, error) }
type TokenValidator interface { VerifyToken(token string, route string) (*TokenInfo, error) }
type TokenService  interface { TokenGenerator; TokenValidator }

Si je veux tester un handler, j’injecte un mock qui implémente l’interface. Pas besoin de base de données, pas besoin de serveur HTTP.

Pourquoi pas la même archi qu’en Python ?

En Python, les couches classiques (router → service → repository) suffisent. FastAPI gère déjà l’injection de dépendances avec Depends(), et SQLModel fait le pont entre la base et la validation. Pas besoin d’ajouter une couche d’abstraction en plus.

En Go, les interfaces sont natives et gratuites en termes de complexité. Le pattern Ports & Adapters s’impose naturellement parce que Go n’a pas de framework d’injection. C’est le constructeur qui reçoit les dépendances, et les interfaces garantissent qu’on peut les remplacer dans les tests. En Python, Depends() fait ce travail pour moi.

Les tests : trois niveaux, mêmes règles partout

Que ce soit en Go ou en Python, je sépare les tests de la même façon :

  • Unit : une fonction, pas de réseau, pas de base. Doit tourner en moins d’une seconde.
  • Integration : API + base de données de test. Moins de 10 secondes.
  • E2E : workflow complet dans Docker Compose.

En Python, ça donne quelque chose comme ça :

# tests/unit/test_transaction_service.py
def test_calculate_total_should_include_tax_when_country_is_fr():
    service = TransactionService()
    result = service.calculate_total(amount=100, country="FR")
    assert result == 120.0  # TVA 20%

def test_calculate_total_should_skip_tax_when_country_is_unknown():
    service = TransactionService()
    result = service.calculate_total(amount=100, country="XX")
    assert result == 100.0

Pas de mock, pas de base, pas de réseau. Si ça échoue, le nom du test me dit exactement quoi et dans quelle condition.

Un point important : la base de données de test doit avoir test dans son nom. Le code vérifie ça au démarrage et refuse de s’exécuter sinon. Je me suis fait peur une fois avec une variable d’env mal configurée qui pointait vers la base de dev.

Docker et CI : le même pattern

Le Dockerfile suit toujours le même pattern multi-stage. L’idée c’est : image de prod sous 100MB, utilisateur non-root, healthcheck intégré, build reproductible via lockfile.

# Exemple Python avec uv
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ ./src/

FROM python:3.12-slim
RUN useradd -r -s /bin/false appuser
WORKDIR /app
COPY --from=builder /app /app
USER appuser
HEALTHCHECK CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0"]

Le --frozen est important : uv refuse de tourner si le lockfile n’est pas à jour. Pas de surprise en prod.

Pour le dev, Docker Compose fournit la base PostgreSQL avec healthcheck. L’app ne démarre que quand la base est prête :

# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app_dev
      POSTGRES_PASSWORD: dev
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 5

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
    env_file: .env
    ports:
      - "8000:8000"

Les tests utilisent un compose séparé avec la base en tmpfs pour aller plus vite, et un port décalé (5433) pour ne pas entrer en conflit avec le dev local :

# docker-compose.test.yml
services:
  db-test:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app_test  # "test" dans le nom, obligatoire
      POSTGRES_PASSWORD: test
    ports:
      - "5433:5432"
    tmpfs:
      - /var/lib/postgresql/data

Le tmpfs monte la base en RAM - les tests d’intégration passent deux fois plus vite, et la base repart de zéro à chaque docker compose up.

Le pipeline CI est identique partout : format check, lint, tests + coverage, security scan, Docker build. Le Taskfile.yml expose tout ça en une commande : task ci. C’est la même commande en local et en CI - pas de “ça passe sur ma machine mais pas en CI”.

Ce que ça m’a apporté

En mission, ces guidelines me permettaient d’onboarder un dev en une demi-journée. Il ouvre le repo, il voit la structure, il tape task run-dev et ça tourne. Pas besoin de lui expliquer pendant deux heures où sont les tests ou comment lancer le linter.

Sur mes projets perso, c’est pareil. Je démarre un nouveau projet en moins de 15 minutes. Et quand je reviens dessus après plusieurs mois, je ne perds plus de temps à me demander comment les choses fonctionnent. La structure est la même, les commandes sont les mêmes.

Code source

Les fichiers de configuration complets sont disponibles sur GitHub :

  • .mise.toml - Gestion des versions d’outils (Python, Go, Node)
  • .envrc - Configuration direnv (Python et Go)
  • pyproject.toml - Configuration Ruff, pytest, mypy, coverage
  • .pre-commit-config.yaml - Hooks pre-commit Go et Python
  • Taskfile.yml - Commandes de dev standardisées
  • .golangci.yml - Configuration golangci-lint
  • .air.toml - Hot-reload Go
  • Dockerfile - Build multi-stage
  • docker-compose.yml - Stack de dev avec PostgreSQL
  • docker-compose.test.yml - Stack de test isolée