Partie 2: Créer votre premier domaine (Posts)¶
circle Partie 2/4 - Temps estimé: 30 minutes
Objectif¶
Dans cette partie, vous allez implémenter le domaine Posts (articles de blog) en suivant l'architecture hexagonale.
Ce que vous allez créer: - Entité Post avec GORM - Interface PostService (port) - Implémentation du service Post - Interface PostRepository (port) - Implémentation du repository Post
Étape 5: Ajouter le domaine Post (Article)¶
Nous allons maintenant ajouter notre première fonctionnalité: les articles de blog.
5.1 Créer l'entité Post¶
Créer le fichier internal/models/post.go:
package models
import (
"strings"
"time"
"gorm.io/gorm"
)
// Post représente un article de blog
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Contenu
Title string `gorm:"not null;size:255" json:"title" validate:"required,max=255"`
Slug string `gorm:"uniqueIndex;not null;size:255" json:"slug"`
Content string `gorm:"type:text;not null" json:"content" validate:"required"`
// Métadonnées
Tags string `gorm:"size:500" json:"tags"`
Published bool `gorm:"default:false" json:"published"`
// Relations
AuthorID uint `gorm:"not null" json:"author_id"`
}
// BeforeCreate génère automatiquement un slug unique avant l'insertion
func (p *Post) BeforeCreate(tx *gorm.DB) error {
if p.Slug == "" {
p.Slug = slugify(p.Title)
}
return nil
}
// slugify convertit un titre en slug URL-friendly
// Exemple: "Mon Super Article!" -> "mon-super-article"
func slugify(title string) string {
slug := strings.ToLower(title)
slug = strings.ReplaceAll(slug, " ", "-")
// Supprimer les caractères spéciaux
replacer := strings.NewReplacer(
"!", "", "?", "", ".", "", ",", "",
"'", "", "\"", "", ":", "", ";", "",
"(", "", ")", "", "[", "", "]", "",
)
slug = replacer.Replace(slug)
// Supprimer les tirets multiples
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
}
// Supprimer les tirets en début/fin
slug = strings.Trim(slug, "-")
return slug
}
Explications:
- struct Post: Définit la structure d'un article
ID,CreatedAt,UpdatedAt,DeletedAt: Champs GORM standardTitle,Content: Contenu de l'articleSlug: URL-friendly version du titre (ex: "mon-article")Tags: Tags séparés par virgulePublished: Boolean pour publier/dépublier-
AuthorID: Référence à l'utilisateur (User.ID) -
BeforeCreate: Hook GORM qui s'exécute avant l'insertion en DB
-
Génère automatiquement le slug depuis le titre
-
slugify: Fonction helper pour créer un slug
- "Mon Super Article!" devient "mon-super-article"
Étape 6: Implémenter le service Post¶
6.1 Définir l'interface PostService¶
Créer internal/interfaces/post_service.go:
package interfaces
import (
"context"
"blog-api/internal/models"
)
// PostService définit les opérations métier sur les articles
type PostService interface {
Create(ctx context.Context, authorID uint, title, content, tags string) (*models.Post, error)
GetByID(ctx context.Context, id uint) (*models.Post, error)
GetBySlug(ctx context.Context, slug string) (*models.Post, error)
List(ctx context.Context, limit, offset int) ([]*models.Post, int64, error)
ListByAuthor(ctx context.Context, authorID uint, limit, offset int) ([]*models.Post, int64, error)
Update(ctx context.Context, id uint, title, content, tags *string) (*models.Post, error)
Publish(ctx context.Context, id uint) error
Unpublish(ctx context.Context, id uint) error
Delete(ctx context.Context, id uint) error
}
6.2 Définir l'interface PostRepository¶
Créer internal/interfaces/post_repository.go:
package interfaces
import (
"context"
"blog-api/internal/models"
)
// PostRepository définit les opérations de persistance pour les articles
type PostRepository interface {
Create(ctx context.Context, post *models.Post) error
FindByID(ctx context.Context, id uint) (*models.Post, error)
FindBySlug(ctx context.Context, slug string) (*models.Post, error)
FindAll(ctx context.Context, limit, offset int) ([]*models.Post, int64, error)
FindByAuthorID(ctx context.Context, authorID uint, limit, offset int) ([]*models.Post, int64, error)
Update(ctx context.Context, post *models.Post) error
Delete(ctx context.Context, id uint) error
}
6.3 Implémenter le service¶
Créer internal/domain/post/service.go:
package post
import (
"context"
"blog-api/internal/domain"
"blog-api/internal/interfaces"
"github.com/rs/zerolog"
)
type service struct {
repo interfaces.PostRepository
logger zerolog.Logger
}
// NewService crée une nouvelle instance du service Post
func NewService(repo interfaces.PostRepository, logger zerolog.Logger) interfaces.PostService {
return &service{
repo: repo,
logger: logger,
}
}
// Create crée un nouvel article
func (s *service) Create(ctx context.Context, authorID uint, title, content, tags string) (*Post, error) {
post := &Post{
Title: title,
Content: content,
Tags: tags,
AuthorID: authorID,
Published: false,
}
if err := s.repo.Create(ctx, post); err != nil {
s.logger.Error().Err(err).Msg("Failed to create post")
return nil, err
}
s.logger.Info().
Uint("post_id", post.ID).
Uint("author_id", authorID).
Str("title", title).
Msg("Post created successfully")
return post, nil
}
// GetByID récupère un article par son ID
func (s *service) GetByID(ctx context.Context, id uint) (*Post, error) {
post, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, domain.NewNotFoundError("Post not found", "POST_NOT_FOUND", err)
}
return post, nil
}
// GetBySlug récupère un article par son slug
func (s *service) GetBySlug(ctx context.Context, slug string) (*Post, error) {
post, err := s.repo.FindBySlug(ctx, slug)
if err != nil {
return nil, domain.NewNotFoundError("Post not found", "POST_NOT_FOUND", err)
}
return post, nil
}
// List récupère tous les articles avec pagination
func (s *service) List(ctx context.Context, limit, offset int) ([]*Post, int64, error) {
return s.repo.FindAll(ctx, limit, offset)
}
// ListByAuthor récupère les articles d'un auteur avec pagination
func (s *service) ListByAuthor(ctx context.Context, authorID uint, limit, offset int) ([]*Post, int64, error) {
return s.repo.FindByAuthorID(ctx, authorID, limit, offset)
}
// Update met à jour un article
func (s *service) Update(ctx context.Context, id uint, title, content, tags *string) (*Post, error) {
post, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, domain.NewNotFoundError("Post not found", "POST_NOT_FOUND", err)
}
// Mettre à jour uniquement les champs fournis
if title != nil {
post.Title = *title
post.Slug = slugify(*title) // Régénérer le slug
}
if content != nil {
post.Content = *content
}
if tags != nil {
post.Tags = *tags
}
if err := s.repo.Update(ctx, post); err != nil {
s.logger.Error().Err(err).Uint("post_id", id).Msg("Failed to update post")
return nil, err
}
s.logger.Info().Uint("post_id", id).Msg("Post updated successfully")
return post, nil
}
// Publish publie un article
func (s *service) Publish(ctx context.Context, id uint) error {
post, err := s.repo.FindByID(ctx, id)
if err != nil {
return domain.NewNotFoundError("Post not found", "POST_NOT_FOUND", err)
}
post.Published = true
if err := s.repo.Update(ctx, post); err != nil {
s.logger.Error().Err(err).Uint("post_id", id).Msg("Failed to publish post")
return err
}
s.logger.Info().Uint("post_id", id).Msg("Post published successfully")
return nil
}
// Unpublish dépublie un article
func (s *service) Unpublish(ctx context.Context, id uint) error {
post, err := s.repo.FindByID(ctx, id)
if err != nil {
return domain.NewNotFoundError("Post not found", "POST_NOT_FOUND", err)
}
post.Published = false
if err := s.repo.Update(ctx, post); err != nil {
s.logger.Error().Err(err).Uint("post_id", id).Msg("Failed to unpublish post")
return err
}
s.logger.Info().Uint("post_id", id).Msg("Post unpublished successfully")
return nil
}
// Delete supprime un article (soft delete)
func (s *service) Delete(ctx context.Context, id uint) error {
if err := s.repo.Delete(ctx, id); err != nil {
s.logger.Error().Err(err).Uint("post_id", id).Msg("Failed to delete post")
return domain.NewNotFoundError("Post not found", "POST_NOT_FOUND", err)
}
s.logger.Info().Uint("post_id", id).Msg("Post deleted successfully")
return nil
}
Points clés:
- Dependency Injection: Le service reçoit le repository et le logger via le constructeur
- Error handling: Utilise les erreurs du domaine (
domain.NewNotFoundError) - Logging structuré: Log avec zerolog pour chaque opération
- Business logic: Gère la publication/dépublication, la génération de slug, etc.
Étape 7: Créer le repository Post¶
Créer internal/adapters/repository/post_repository.go:
package repository
import (
"context"
"blog-api/internal/models"
"blog-api/internal/interfaces"
"gorm.io/gorm"
)
type postRepository struct {
db *gorm.DB
}
// NewPostRepository crée une nouvelle instance du repository Post
func NewPostRepository(db *gorm.DB) interfaces.PostRepository {
return &postRepository{db: db}
}
// Create insère un nouvel article dans la base de données
func (r *postRepository) Create(ctx context.Context, post *models.Post) error {
return r.db.WithContext(ctx).Create(post).Error
}
// FindByID récupère un article par son ID
func (r *postRepository) FindByID(ctx context.Context, id uint) (*models.Post, error) {
var p post.Post
err := r.db.WithContext(ctx).First(&p, id).Error
if err != nil {
return nil, err
}
return &p, nil
}
// FindBySlug récupère un article par son slug
func (r *postRepository) FindBySlug(ctx context.Context, slug string) (*models.Post, error) {
var p post.Post
err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&p).Error
if err != nil {
return nil, err
}
return &p, nil
}
// FindAll récupère tous les articles avec pagination
// Retourne les posts + le total count
func (r *postRepository) FindAll(ctx context.Context, limit, offset int) ([]*models.Post, int64, error) {
var posts []*models.Post
var total int64
// Count total
if err := r.db.WithContext(ctx).Model(&models.Post{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Récupérer les posts
err := r.db.WithContext(ctx).
Limit(limit).
Offset(offset).
Order("created_at DESC").
Find(&posts).Error
return posts, total, err
}
// FindByAuthorID récupère les articles d'un auteur avec pagination
func (r *postRepository) FindByAuthorID(ctx context.Context, authorID uint, limit, offset int) ([]*models.Post, int64, error) {
var posts []*models.Post
var total int64
query := r.db.WithContext(ctx).Where("author_id = ?", authorID)
// Count total
if err := query.Model(&models.Post{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Récupérer les posts
err := query.
Limit(limit).
Offset(offset).
Order("created_at DESC").
Find(&posts).Error
return posts, total, err
}
// Update met à jour un article
func (r *postRepository) Update(ctx context.Context, post *models.Post) error {
return r.db.WithContext(ctx).Save(post).Error
}
// Delete supprime un article (soft delete avec GORM)
func (r *postRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&models.Post{}, id).Error
}
Points clés:
- GORM: Utilise GORM pour interagir avec PostgreSQL
- Context: Chaque méthode accepte un context pour les timeouts/annulations
- Pagination: FindAll et FindByAuthorID retournent total count + posts
- Soft Delete: GORM gère automatiquement le soft delete via DeletedAt
Résumé de la Partie 2¶
check Création de l'entité Post avec GORM check Définition des interfaces (PostService, PostRepository) check Implémentation du service Post avec business logic check Implémentation du repository Post avec GORM
check_circle Checkpoint: Le domaine Post est implémenté (service + repository)
Dans la prochaine partie, nous allons exposer ce domaine via l'API HTTP.
Navigation¶
arrow_back Partie 1: Installation et Configuration | Partie 3: Exposer l'API HTTP arrow_forward