Le pattern Singleton pour le contenu CMS dans NestJS

Le pattern Singleton pour le contenu CMS dans NestJS

Lorsqu'on construit un back-office pour un site vitrine, on tombe rapidement sur un cas particulier : certaines pages n'ont qu'une seule version de leur contenu. La page d'accueil, la page « À propos », les mentions légales… Il n'y a aucune raison d'en gérer plusieurs instances. Pourtant, l'API REST classique (avec POST, GET, PUT, DELETE) suppose qu'on manipule une collection de ressources. Comment adapter ce modèle pour un contenu unique par page ?

Dans cet article, nous allons explorer le pattern Singleton appliqué au contenu CMS dans NestJS. Nous verrons d'abord la version « une entité par page », puis l'alternative plus flexible utilisée dans le repository site-voyance : une table SiteContent centralisée avec un système de clés. L'objectif : offrir une API simple, prévisible, et sans ID à gérer côté frontend.

Le besoin : un contenu unique par page

Imaginons un site vitrine avec plusieurs pages : accueil, à propos, contact, réservation. Chacune contient des textes éditables par l'administrateur : un titre, un sous-titre, un CTA, une description. Ces textes ne sont jamais dupliqués : il n'y a qu'un seul titre pour la page d'accueil à un instant T.

Si l'on modélise ça comme une collection REST classique, on se retrouve avec :

  • un POST /home-page qui ne devrait être appelé qu'une seule fois,
  • un GET /home-page/:id alors que le frontend ne connaît pas cet ID,
  • un DELETE qui n'a aucun sens métier.

Le pattern Singleton apporte une solution élégante : on expose le contenu directement, sans jamais exposer d'identifiant, et on garantit qu'il existe toujours grâce à un mécanisme de findOrCreate().

Prérequis

  • Node.js 18+ et un projet NestJS 10+ initialisé
  • TypeORM et une base de données (PostgreSQL dans nos exemples)
  • Connaissance des bases de NestJS : modules, services, controllers

Approche 1 : une entité par page

La première approche, la plus naïve, consiste à créer une entité dédiée à chaque page. Pour la page d'accueil :

// home-page-content.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn } from 'typeorm';

@Entity('home_page_content')
export class HomePageContent {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'hero_title', type: 'varchar', length: 255 })
  heroTitle: string;

  @Column({ name: 'hero_subtitle', type: 'varchar', length: 255 })
  heroSubtitle: string;

  @Column({ name: 'hero_description', type: 'text' })
  heroDescription: string;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}

Le service avec findOrCreate()

C'est ici que le pattern Singleton prend tout son sens. Le service garantit qu'une et une seule entrée existe en base. Si elle n'existe pas, il la crée à la volée :

// home-page.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HomePageContent } from '../../database/entities/home-page-content.entity';
import { UpdateHomePageDto } from './dto/update-home-page.dto';

@Injectable()
export class HomePageService {
  constructor(
    @InjectRepository(HomePageContent)
    private readonly repository: Repository<HomePageContent>,
  ) {}

  async findOrCreate(): Promise<HomePageContent> {
    let content = await this.repository.findOne({ where: {} });

    if (!content) {
      content = this.repository.create({
        heroTitle: 'Titre par défaut',
        heroSubtitle: 'Sous-titre par défaut',
        heroDescription: 'Description par défaut',
      });
      content = await this.repository.save(content);
    }

    return content;
  }

  async update(dto: UpdateHomePageDto): Promise<HomePageContent> {
    const content = await this.findOrCreate();
    Object.assign(content, dto);
    return this.repository.save(content);
  }
}

Le controller : pas de POST, pas de DELETE

Le controller expose seulement deux verbes : GET pour lire (avec création automatique si besoin) et PATCH pour mettre à jour. Aucun POST, aucun DELETE, aucun paramètre :id dans l'URL.

// home-page.controller.ts
import { Controller, Get, Patch, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { HomePageService } from './home-page.service';
import { UpdateHomePageDto } from './dto/update-home-page.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { HomePageContent } from '../../database/entities/home-page-content.entity';
import { Public } from '../../common/decorators/public.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../../database/entities/user.entity';

@ApiTags('home-page')
@Controller('pages/home')
@ApiBearerAuth()
export class HomePageController {
  constructor(private readonly homePageService: HomePageService) {}

  @Get()
  @Public()
  @ApiOperation({ summary: 'Get home page content' })
  async get(): Promise<ApiResponseDto<HomePageContent>> {
    const content = await this.homePageService.findOrCreate();
    return ApiResponseDto.success(content, 'Home page retrieved');
  }

  @Patch()
  @Roles(UserRole.ADMIN)
  @ApiOperation({ summary: 'Update home page content' })
  async update(
    @Body() dto: UpdateHomePageDto,
  ): Promise<ApiResponseDto<HomePageContent>> {
    const content = await this.homePageService.update(dto);
    return ApiResponseDto.success(content, 'Home page updated');
  }
}

Côté frontend, l'utilisation devient triviale :

const { data } = await api.get('/pages/home');
// Pas besoin de gérer un ID, pas besoin de vérifier si l'entrée existe

Approche 2 : une table SiteContent centralisée

Créer une entité par page fonctionne, mais devient vite verbeux : 5 pages = 5 entités, 5 services, 5 controllers, 5 migrations. C'est l'approche retenue par site-voyance qui inverse le problème : une seule entité SiteContent stocke tous les contenus, identifiés par une key unique (par exemple home.hero.title) et organisés par section, page et group.

Voici comment ContentService expose le contenu de manière flexible, page par page ou globalement :

async findByPage(page: string): Promise<SiteContent[]> {
  return this.contentRepository.find({
    where: { page, isActive: true },
    order: { group: 'ASC', order: 'ASC', key: 'ASC' },
  });
}

async findByPageAndGroup(page: string, group: string): Promise<SiteContent[]> {
  return this.contentRepository.find({
    where: { page, group, isActive: true },
    order: { order: 'ASC', key: 'ASC' },
  });
}

Le seed automatique : un singleton à l'échelle de la base

L'astuce intéressante du repository, c'est l'utilisation du hook OnModuleInit pour garantir qu'au démarrage de l'application, le contenu de référence est toujours présent. C'est une variante du pattern findOrCreate(), mais appliquée à un ensemble :

@Injectable()
export class ContentService implements OnModuleInit {
  async onModuleInit() {
    await this.seed();
  }

  async seed(): Promise<void> {
    const count = await this.contentRepository.count();
    if (count > 0) return;

    const contents: CreateContentDto[] = [
      { key: 'home.hero.title', section: 'home', page: 'home', group: 'hero', order: 0,
        value: 'Éclairez votre chemin intérieur', contentType: 'text' },
      { key: 'home.hero.subtitle', section: 'home', page: 'home', group: 'hero', order: 1,
        value: 'Guidances spirituelles', contentType: 'text' },
      // ...
    ];

    for (const contentData of contents) {
      const existing = await this.contentRepository.findOne({
        where: { key: contentData.key },
      });
      if (!existing) {
        const content = this.contentRepository.create(contentData);
        await this.contentRepository.save(content);
      }
    }
  }
}

Mise à jour par clé

Plutôt que de demander un ID, on met à jour directement par key. Le frontend connaît la clé (elle fait partie du contrat d'interface) et n'a jamais à manipuler d'UUID :

async updateByKey(key: string, value: string): Promise<SiteContent> {
  const content = await this.findByKey(key);
  content.value = value;
  return this.contentRepository.save(content);
}

Et côté controller :

@Patch('key/:key')
@Roles(UserRole.ADMIN)
async updateByKey(
  @Param('key') key: string,
  @Body('value') value: string,
): Promise<ApiResponseDto<SiteContent>> {
  const content = await this.contentService.updateByKey(key, value);
  return ApiResponseDto.success(content, 'Content updated successfully');
}

Le bonus : structure d'objet pour le frontend

Au lieu de renvoyer un tableau plat, on peut structurer le contenu pour qu'il soit directement consommable côté frontend, avec un accès du type content.home.heroTitle :

async getAllAsObject(): Promise<Record<string, Record<string, string>>> {
  const allContent = await this.findAll();
  const result: Record<string, Record<string, string>> = {};

  for (const content of allContent) {
    if (!result[content.section]) {
      result[content.section] = {};
    }
    const keyWithoutSection = content.key.replace(`${content.section}.`, '');
    result[content.section][keyWithoutSection] = content.value;
  }

  return result;
}

Mise en cache du contenu statique

Le contenu CMS change rarement mais est lu à chaque chargement de page. C'est le candidat parfait pour la mise en cache. NestJS fournit CacheModule qui s'intègre facilement :

import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager';
import { UseInterceptors } from '@nestjs/common';

@Get('object')
@Public()
@UseInterceptors(CacheInterceptor)
@CacheKey('content:object')
@CacheTTL(300) // 5 minutes
async getAllAsObject(): Promise<ApiResponseDto<Record<string, Record<string, string>>>> {
  const content = await this.contentService.getAllAsObject();
  return ApiResponseDto.success(content, 'Content retrieved successfully');
}

Pensez à invalider le cache après chaque update() ou updateByKey() via cacheManager.del('content:object'), sinon les changements ne seront pas visibles avant l'expiration du TTL.

Quelle approche choisir ?

  • Une entité par page : idéale pour des pages au schéma fixe et bien défini, avec validation forte et types stricts. Vous gagnez en sécurité de typage mais perdez en flexibilité.
  • Table SiteContent centralisée : parfaite pour un site vitrine avec beaucoup de petits textes éditables. L'admin peut ajouter de nouvelles clés sans modifier le schéma. C'est l'approche la plus pragmatique pour un CMS léger.

Conclusion

Le pattern Singleton appliqué au contenu CMS résout élégamment un problème courant : exposer un contenu unique sans imposer la complexité d'une collection REST. Les bénéfices sont nombreux : API plus simple côté frontend (pas d'ID à gérer), impossibilité de créer des doublons, et contenu toujours disponible grâce à findOrCreate() ou au seed initial.

Pour aller plus loin, vous pouvez explorer la versioning du contenu (historique des modifications), la traduction multi-langues via une table content_translations, ou encore la prévisualisation en mode draft avant publication. Le pattern reste le même : un endpoint par identité métier, et la garantie qu'il renvoie toujours quelque chose de cohérent.

Continuer la lecture

Article suivant — NestJS Organiser ses tests et créer des helpers dans NestJS Explorer tout : NestJS

Commentaires

Soyez le premier à laisser un commentaire — le robot attend.

Laisser un commentaire

Les champs obligatoires sont indiqués avec *