Note d'architecture

Couche de services Go

Tranche 1
Worklo · Ingénieur Senior Go + Next.js 28.07.2026

Vous écrivez que « il nous faut Go pour la performance » n'est pas une vraie raison. Voici mes réponses à vos trois vraies raisons.

Cette note traite vos trois traitements un par un, avec le code du socle, les deux points d'intégration Supabase à trancher avant la première ligne, et la position que je défendrais sur les contrats entre services. Sur l'un des trois, mon avis diffère du vôtre.

Capacités

Go, oui

avec une frontière SQL à poser

Workflows

Go, oui

mais pas à la main

Analytique

à mesurer

PostgreSQL suffit peut-être

01 / LES TROIS TRAITEMENTS

Ce que je sortirais de Node, et ce que je laisserais.

D'accord avec vous

Une allocation proportionnelle qui se cumule de l'individu au département puis à l'organisation est un calcul en arbre. L'exécuter sur l'event loop bloque le processus entier, votre diagnostic est juste.

En Go, la forme naturelle est un errgroup avec limite de parallélisme et propagation du contexte, pour que l'annulation traverse réellement la chaîne.

La question que je poserais

Une partie du roll-up peut se faire en CTE récursive côté PostgreSQL. La vraie question n'est pas Go ou SQL, c'est où placer la frontière. Elle se tranche sur vos volumes réels, pas sur un principe.

capacity/engine.go

// Le contexte porte l'annulation : si le client HTTP coupe,
// tous les calculs de département s'arrêtent avec lui.
func (e *Engine) ComputeOrg(ctx context.Context, orgID string) (Rollup, error) {
	depts, err := e.repo.Departments(ctx, orgID)
	if err != nil {
		return Rollup{}, fmt.Errorf("load departments: %w", err)
	}

	g, gctx := errgroup.WithContext(ctx)
	g.SetLimit(runtime.GOMAXPROCS(0))

	loads := make([]DeptLoad, len(depts))
	for i, d := range depts {
		g.Go(func() error {
			l, err := e.computeDept(gctx, d)
			if err != nil {
				return fmt.Errorf("dept %s: %w", d.ID, err)
			}
			loads[i] = l // index disjoint, pas de mutex
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		return Rollup{}, err
	}
	return rollUp(loads), nil
}
D'accord sur le principe, une réserve sur la méthode

Les chaînes d'approbation multi-étapes n'ont effectivement pas leur place dans un cycle requête/réponse. En revanche, écrire à la main des machines à états durables avec reprises est le piège de conception de ce projet. Ça tient pour trois cas, puis la reprise partielle, la reprise après incident et le versionnement des définitions font exploser la complexité.

River, avec l'édition Pro

votre PostgreSQL
  • Mise en file transactionnelle native, motif outbox : la tâche ne part que si votre transaction passe
  • Workflows V2 sortis en 2026 : signaux durables, timers, conditions d'attente CEL
  • Jobs reprenables, les étapes déjà faites sont sautées au retry
  • Aucun service supplémentaire à exploiter

Réserve à connaître : les workflows V2 sont dans River Pro, extension payante et propriétaire. Le cœur open source ne fournit que la file.

Temporal

cluster séparé
  • Rejeu déterministe de code arbitraire, versionnement mature
  • SDK Go, Python, Java, TypeScript
  • Le bon choix si les workflows appellent des API externes ou traversent plusieurs langages

Contrepartie : un cluster à exploiter ou une facturation à l'action sur Temporal Cloud, et pas de mise en file transactionnelle avec votre base.

Ma position : vos workflows d'approbation restent internes à Worklo et touchent surtout vos tables PostgreSQL. River part donc favori, à condition d'assumer la licence Pro. Si vos chaînes appellent des services tiers, l'arbitrage bascule vers Temporal. Cette décision se prend en regardant vos définitions réelles, pas un tableau comparatif.

C'est ici que je ne suis pas d'accord

C'est la moins solide de vos trois justifications, et je préfère le dire maintenant plutôt qu'après avoir facturé un service. Vous écrivez vouloir des vues matérialisées et des index partiels. Si PostgreSQL fait le travail, un service Go dédié ajoute une frontière réseau, une sérialisation et un déploiement, sans gain mesurable.

ÉTAPE 1

Mesurer

Plans d'exécution sur vos agrégations réelles, et part du budget de latence réellement consommée.

ÉTAPE 2

Vues matérialisées

Avec index unique, sans quoi REFRESH CONCURRENTLY est impossible et le rafraîchissement pose un verrou exclusif.

ÉTAPE 3

Extraire, si nécessaire

Le service Go devient justifié quand le rafraîchissement devient le point de contention, pas avant.

Vous écrivez que les volumes actuels sont modérés et que Postgres les gère très bien. C'est exactement l'argument qui rend ce troisième service prématuré. Les deux autres, eux, sont justifiés dès aujourd'hui.

02 / PGX ET SUPABASE

Deux points à trancher avant la première ligne de code.

Vous prévoyez des connexions directes avec pgx en contournant PostgREST pour les lectures internes à fort volume. C'est le bon choix. Il a deux conséquences précises, dont une de sécurité.

Point 1, sous charge

Le cache de requêtes préparées casse derrière le pooler

Supavisor en mode transaction, port 6543, réassigne les connexions à la volée. Rien ne garantit qu'une requête préparée existe encore dans la session qui l'exécute.

pgx prépare et met en cache automatiquement. Sans configuration explicite, vous verrez apparaître prepared statement "stmtcache_xx" does not exist, et vous les verrez sous charge, c'est-à-dire au pire moment.

Alternative si vos services Go ne montent pas et ne descendent pas en permanence : rester en mode session sur le port 5432.

db/pool.go

func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		return nil, fmt.Errorf("parse dsn: %w", err)
	}

	// Supavisor en mode transaction ne conserve pas les
	// prepared statements nommés d'une requête à l'autre.
	cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec
	cfg.ConnConfig.StatementCacheCapacity = 0
	cfg.ConnConfig.DescriptionCacheCapacity = 0

	cfg.MaxConns = 12
	cfg.MaxConnLifetime = 30 * time.Minute
	cfg.MaxConnIdleTime = 5 * time.Minute

	return pgxpool.NewWithConfig(ctx, cfg)
}

Point 2, sécurité

La connexion directe désactive vos politiques RLS

Les politiques RLS de Supabase s'appuient sur auth.uid() et current_setting('request.jwt.claims'), que PostgREST renseigne à partir du jeton. En connexion directe avec un rôle privilégié, ces valeurs sont nulles et les politiques ne s'appliquent plus. Le rôle postgres dispose de surcroît de BYPASSRLS.

Sur une plateforme où chaque client pilote ses opérations, l'isolation entre organisations redevient donc une responsabilité applicative. Ce n'est pas un problème en soi, à condition de le décider plutôt que de le subir.

Ce que je proposerais

L'option 2 pour tout ce qui touche aux données d'un locataire, l'option 1 réservée aux agrégats internes qui ne sortent jamais du service. Le surcoût d'un set_config par transaction est négligeable devant le risque d'une fuite inter-organisations.

OPTION 1, filtrage applicatif

-- La garantie repose sur la discipline de revue.
SELECT a.user_id, a.hours, a.week
FROM   allocations a
JOIN   projects p ON p.id = a.project_id
WHERE  p.org_id = $1
  AND  a.week BETWEEN $2 AND $3

Simple et rapide. Un WHERE oublié dans six mois devient une fuite entre organisations.

OPTION 2, RLS conservé

// Rôle authenticated + claims posés par transaction :
// les politiques RLS restent le garde-fou.
tx, err := pool.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx)

if _, err = tx.Exec(ctx,
	"SELECT set_config('request.jwt.claims', $1, true)",
	claimsJSON); err != nil {
	return fmt.Errorf("set claims: %w", err)
}

Plus verbeux, mais l'isolation redescend au niveau de la base, là où elle ne dépend plus d'une relecture humaine.

03 / CONTRATS ENTRE SERVICES

gRPC en interne, et voici les trois raisons.

Vous laissez le choix entre REST et gRPC. Je défendrais gRPC, pour des motifs concrets plutôt que par principe.

RAISON 01

Le contrat ne dérive pas

Les schémas vivent dans les fichiers proto et sont versionnés. Le client Express et le service Go ne peuvent pas diverger en silence, ce qui arrive toujours avec du JSON non typé partagé entre deux dépôts.

RAISON 02

Le streaming sert votre vue capacité

Le streaming serveur permet de remonter les résultats département par département au lieu d'attendre le total de l'organisation. C'est ce qui rend le TTFB de votre vue capacité réellement rapide, pas seulement le rendu React.

RAISON 03

L'annulation traverse la chaîne

Contexte et délais sont natifs. Quand un utilisateur ferme l'onglet, le calcul s'arrête vraiment au lieu de continuer à consommer du CPU jusqu'au bout.

La contrepartie, que je préfère annoncer

Outillage supplémentaire côté Node et débogage moins direct qu'avec du JSON lisible dans un onglet réseau. Si l'équipe préfère rester sur REST, la position se défend, à condition de figer les schémas et de les versionner malgré tout. Ce serait mon second choix, pas une erreur.

proto/capacity/v1/capacity.proto

service CapacityService {
  // Streaming : un message par département,
  // le front affiche au fil de l'eau.
  rpc ComputeOrg(ComputeOrgRequest)
      returns (stream DeptLoad);
}

message ComputeOrgRequest {
  string org_id   = 1;
  string week_from = 2;
  string week_to   = 3;
}

Authentification et erreurs

Le jeton de l'utilisateur final voyage en métadonnée et le service Go le valide, plutôt que de faire confiance à l'appelant. Un jeton de service distinct identifie l'appelant technique. Les deux ne remplissent pas le même rôle et les confondre est une erreur classique.

Côté erreurs, les codes gRPC applicatifs sont traduits en statuts HTTP par Express, avec un identifiant de corrélation présent des deux côtés. Sans lui, un incident se débogue en lisant deux journaux sans point commun.

04 / LES PAGES NEXT.JS

Sur la vue capacité, le vrai sujet n'est pas le rendu.

C'est la cascade de requêtes. Un composant serveur qui attend le calcul complet avant de rendre quoi que ce soit ne fait que déplacer l'attente, et le streaming ne rattrape pas une séquence mal ordonnée.

app/(org)/capacity/page.tsx

// La coque et l'entête partent immédiatement.
// Chaque bloc lourd suspend indépendamment.
export default async function Page({ params }) {
  // Lancées en parallèle, pas en séquence :
  // les promesses partent avant tout await.
  const capacity = getOrgCapacity(params.orgID)
  const members  = getMembers(params.orgID)

  return (
    <Shell>
      <Header orgID={params.orgID} />

      <Suspense fallback={<GridSkeleton />}>
        <CapacityGrid data={capacity} />
      </Suspense>

      <Suspense fallback={<ListSkeleton />}>
        <MemberList data={members} />
      </Suspense>
    </Shell>
  )
}

Vue capacité à l'échelle de l'organisation

Grille dense, potentiellement des centaines de lignes par semaine. Fenêtrage côté client, et les résultats du service Go affichés par département au fur et à mesure qu'ils arrivent par le flux gRPC.

Tableau de bord analytique

Les agrégats lourds sont les meilleurs candidats au streaming : les indicateurs de tête s'affichent pendant que les graphiques se calculent encore.

Panneau d'administration

Moins exigeant en performance, plus exigeant en droits. Les contrôles d'accès se posent côté serveur, jamais en masquant des boutons.

05 / OBSERVABILITÉ

La métrique qui compte n'est pas la latence moyenne.

Sur des traitements longs, la moyenne ment. Ce qu'il faut instrumenter, c'est le temps d'attente en file et la saturation des worker pools. Une latence de calcul stable avec une file qui s'allonge annonce l'incident bien avant qu'il ne se voie.

LOGS

Structurés, avec un identifiant de corrélation qui traverse Express, Go et PostgreSQL.

MÉTRIQUES

Prometheus sur les durées de calcul, la profondeur des files et le temps d'attente avant traitement.

TRACES

OpenTelemetry de bout en bout, pour voir où passe réellement le temps entre l'API et la base.

SONDES

Vivacité et disponibilité distinctes. Les confondre fait redémarrer des services qui attendent simplement la base.

06 / PREMIÈRE TRANCHE

Le socle, le moteur de capacité, et la vue qui le consomme.

Une tranche qui livre une chaîne complète de bout en bout plutôt que trois moitiés de services. À l'arrivée, le calcul de capacité tourne en Go, il est appelé par l'API, et votre vue organisation l'affiche en streaming.

LotJours
Conception et contrats4,0
Socle Go5,0
Moteur de capacité8,0
Vue capacité Next.js6,0
Optimisations PostgreSQL4,0
Intégration continue et tests5,0
Tranche 132,0 j

Montant

20 800 € HT

32 jours à 650 € le jour

Rythme

Démonstration en fin de chaque semaine sur le code réel, décisions d'architecture documentées au fil de l'eau.

Tranche suivante

Moteur d'exécution des workflows, tableau de bord analytique, panneau d'administration. Chiffrée quand la première tranche tourne.

Discutons-en

Trois questions ouvertes, de mon côté.

Vos définitions de workflows appellent-elles des services externes, ce qui ferait basculer l'arbitrage vers Temporal. Vos agrégations analytiques ont-elles déjà été mesurées sous charge réelle. Et sur quel port vos services se connectent-ils aujourd'hui à Supabase.

15 ans de développement, Go en production, Next.js et PostgreSQL au quotidien. Je réponds en moins de 4 heures les jours ouvrés.

Vous préférez passer par la plateforme ? Répondez-moi directement sur le projet, ça fonctionne aussi.