Note d'architecture
Couche de services Go
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
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
}
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 PostgreSQLRé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é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 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
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
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é
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
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
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É
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
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.
| Lot | Jours |
|---|---|
| Conception et contrats | 4,0 |
| Socle Go | 5,0 |
| Moteur de capacité | 8,0 |
| Vue capacité Next.js | 6,0 |
| Optimisations PostgreSQL | 4,0 |
| Intégration continue et tests | 5,0 |
| Tranche 1 | 32,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
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.