Skip to content

Go-DDDマーケットプレイスアプリケーション 商品管理機能の実装手順

1. 概要

この文書では、Go-DDDマーケットプレイスアプリケーションにおける商品管理機能の実装手順を説明します。実装はドメイン駆動設計(DDD)の原則に従い、レイヤードアーキテクチャを使用します。

1.1 商品のライフサイクル

以下は商品エンティティのライフサイクルを表すステート図です:

uml diagram

2. 実装ステップ

以下は商品管理機能の実装ステップの概要を表すフロー図です:

uml diagram

2.1 ドメインレイヤーの実装

2.1.1 商品エンティティの作成

  1. internal/domain/entities/product.go に商品エンティティを定義します。
package entities

import (
    "time"
    "errors"
)

type Product struct {
    Id          string
    Name        string
    Description string
    Price       float64
    SellerId    string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

func (p *Product) Validate() error {
    if p.Name == "" {
        return errors.New("product name cannot be empty")
    }
    if p.Price <= 0 {
        return errors.New("product price must be greater than zero")
    }
    if p.SellerId == "" {
        return errors.New("product must have a seller")
    }
    return nil
}

2.1.2 バリデーション済み商品エンティティの作成

  1. internal/domain/entities/validated_product.go にバリデーション済み商品エンティティを定義します。
package entities

type ValidatedProduct struct {
    Product Product
}

func NewValidatedProduct(product Product) (*ValidatedProduct, error) {
    if err := product.Validate(); err != nil {
        return nil, err
    }
    return &ValidatedProduct{Product: product}, nil
}

2.1.3 商品リポジトリインターフェースの定義

  1. internal/domain/repositories/product_repository.go に商品リポジトリインターフェースを定義します。
package repositories

import (
    "github.com/sklinkert/go-ddd/internal/domain/entities"
)

type ProductRepository interface {
    Save(product entities.Product) error
    FindAll() ([]entities.Product, error)
    ById(id string) (entities.Product, error)
    Update(product entities.Product) error
}

2.2 アプリケーションレイヤーの実装

2.2.1 商品コマンドの作成

  1. internal/application/command/product_commands.go に商品コマンドを定義します。
package command

type CreateProductCommand struct {
    Name        string
    Description string
    Price       float64
    SellerId    string
}

type UpdateProductCommand struct {
    Id          string
    Name        string
    Description string
    Price       float64
}

2.2.2 商品クエリの作成

  1. internal/application/query/product_queries.go に商品クエリを定義します。
package query

type FindAllProductsQuery struct {}

type FindProductByIdQuery struct {
    Id string
}

2.2.3 商品サービスの実装

  1. internal/application/services/product_service.go に商品サービスを実装します。
package services

import (
    "errors"
    "time"
    "github.com/google/uuid"
    "github.com/sklinkert/go-ddd/internal/application/command"
    "github.com/sklinkert/go-ddd/internal/domain/entities"
    "github.com/sklinkert/go-ddd/internal/domain/repositories"
)

type ProductService struct {
    productRepo repositories.ProductRepository
    sellerRepo  repositories.SellerRepository
}

func NewProductService(productRepo repositories.ProductRepository, sellerRepo repositories.SellerRepository) *ProductService {
    return &ProductService{
        productRepo: productRepo,
        sellerRepo:  sellerRepo,
    }
}

func (s *ProductService) Create(cmd command.CreateProductCommand) (entities.Product, error) {
    // 出品者の存在確認
    _, err := s.sellerRepo.ById(cmd.SellerId)
    if err != nil {
        return entities.Product{}, errors.New("seller not found")
    }

    // 商品エンティティの作成
    now := time.Now()
    product := entities.Product{
        Id:          uuid.New().String(),
        Name:        cmd.Name,
        Description: cmd.Description,
        Price:       cmd.Price,
        SellerId:    cmd.SellerId,
        CreatedAt:   now,
        UpdatedAt:   now,
    }

    // バリデーション
    if _, err := entities.NewValidatedProduct(product); err != nil {
        return entities.Product{}, err
    }

    // 保存
    if err := s.productRepo.Save(product); err != nil {
        return entities.Product{}, err
    }

    return product, nil
}

func (s *ProductService) FindAll() ([]entities.Product, error) {
    return s.productRepo.FindAll()
}

func (s *ProductService) ById(id string) (entities.Product, error) {
    return s.productRepo.ById(id)
}

func (s *ProductService) Update(cmd command.UpdateProductCommand) (entities.Product, error) {
    // 既存の商品を取得
    product, err := s.productRepo.ById(cmd.Id)
    if err != nil {
        return entities.Product{}, err
    }

    // 商品を更新
    product.Name = cmd.Name
    product.Description = cmd.Description
    product.Price = cmd.Price
    product.UpdatedAt = time.Now()

    // バリデーション
    if _, err := entities.NewValidatedProduct(product); err != nil {
        return entities.Product{}, err
    }

    // 保存
    if err := s.productRepo.Update(product); err != nil {
        return entities.Product{}, err
    }

    return product, nil
}

2.3 インフラストラクチャレイヤーの実装

2.3.1 商品リポジトリの実装(PostgreSQL)

  1. internal/infrastructure/db/postgres/product_repository.go に商品リポジトリの実装を作成します。
package postgres

import (
    "errors"
    "gorm.io/gorm"
    "github.com/sklinkert/go-ddd/internal/domain/entities"
)

type GormProductRepository struct {
    db *gorm.DB
}

func NewGormProductRepository(db *gorm.DB) *GormProductRepository {
    return &GormProductRepository{db: db}
}

func (r *GormProductRepository) Save(product entities.Product) error {
    dbProduct := toDBProduct(product)
    result := r.db.Create(&dbProduct)
    return result.Error
}

func (r *GormProductRepository) FindAll() ([]entities.Product, error) {
    var dbProducts []DBProduct
    result := r.db.Find(&dbProducts)
    if result.Error != nil {
        return nil, result.Error
    }

    products := make([]entities.Product, len(dbProducts))
    for i, dbProduct := range dbProducts {
        products[i] = toDomainProduct(dbProduct)
    }
    return products, nil
}

func (r *GormProductRepository) ById(id string) (entities.Product, error) {
    var dbProduct DBProduct
    result := r.db.First(&dbProduct, "id = ?", id)
    if result.Error != nil {
        if errors.Is(result.Error, gorm.ErrRecordNotFound) {
            return entities.Product{}, errors.New("product not found")
        }
        return entities.Product{}, result.Error
    }
    return toDomainProduct(dbProduct), nil
}

func (r *GormProductRepository) Update(product entities.Product) error {
    dbProduct := toDBProduct(product)
    result := r.db.Save(&dbProduct)
    return result.Error
}

2.3.2 商品マッパーの実装

  1. internal/infrastructure/db/postgres/product_mapper.go に商品マッパーを実装します。
package postgres

import (
    "time"
    "github.com/sklinkert/go-ddd/internal/domain/entities"
)

type DBProduct struct {
    ID          string `gorm:"primaryKey"`
    Name        string
    Description string
    Price       float64
    SellerID    string
    CreatedAt   time.Time
    UpdatedAt   time.Time
    DeletedAt   *time.Time `gorm:"index"`
}

func (DBProduct) TableName() string {
    return "products"
}

func toDBProduct(product entities.Product) DBProduct {
    return DBProduct{
        ID:          product.Id,
        Name:        product.Name,
        Description: product.Description,
        Price:       product.Price,
        SellerID:    product.SellerId,
        CreatedAt:   product.CreatedAt,
        UpdatedAt:   product.UpdatedAt,
    }
}

func toDomainProduct(dbProduct DBProduct) entities.Product {
    return entities.Product{
        Id:          dbProduct.ID,
        Name:        dbProduct.Name,
        Description: dbProduct.Description,
        Price:       dbProduct.Price,
        SellerId:    dbProduct.SellerID,
        CreatedAt:   dbProduct.CreatedAt,
        UpdatedAt:   dbProduct.UpdatedAt,
    }
}

2.4 インターフェースレイヤーの実装

2.4.1 商品コントローラーの実装

  1. internal/interface/api/rest/product_controller.go に商品コントローラーを実装します。
package rest

import (
    "net/http"
    "time"
    "github.com/labstack/echo/v4"
    "github.com/sklinkert/go-ddd/internal/application/command"
    "github.com/sklinkert/go-ddd/internal/application/services"
)

type ProductController struct {
    productService *services.ProductService
}

func NewProductController(e *echo.Echo, productService *services.ProductService) {
    controller := &ProductController{
        productService: productService,
    }

    api := e.Group("/api/v1")
    api.POST("/products", controller.Create)
    api.GET("/products", controller.FindAll)
    api.GET("/products/:id", controller.ById)
    api.PUT("/products/:id", controller.Update)
}

type CreateProductRequest struct {
    Name        string  `json:"name"`
    Description string  `json:"description"`
    Price       float64 `json:"price"`
    SellerId    string  `json:"sellerId"`
}

type ProductResponse struct {
    Id          string  `json:"id"`
    Name        string  `json:"name"`
    Description string  `json:"description"`
    Price       float64 `json:"price"`
    SellerId    string  `json:"sellerId"`
    CreatedAt   string  `json:"createdAt"`
    UpdatedAt   string  `json:"updatedAt"`
}

func (ctrl *ProductController) Create(c echo.Context) error {
    var req CreateProductRequest
    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
    }

    cmd := command.CreateProductCommand{
        Name:        req.Name,
        Description: req.Description,
        Price:       req.Price,
        SellerId:    req.SellerId,
    }

    product, err := ctrl.productService.Create(cmd)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
    }

    resp := ProductResponse{
        Id:          product.Id,
        Name:        product.Name,
        Description: product.Description,
        Price:       product.Price,
        SellerId:    product.SellerId,
        CreatedAt:   product.CreatedAt.Format(time.RFC3339),
        UpdatedAt:   product.UpdatedAt.Format(time.RFC3339),
    }

    return c.JSON(http.StatusCreated, resp)
}

func (ctrl *ProductController) FindAll(c echo.Context) error {
    products, err := ctrl.productService.FindAll()
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
    }

    var resp []ProductResponse
    for _, product := range products {
        resp = append(resp, ProductResponse{
            Id:          product.Id,
            Name:        product.Name,
            Description: product.Description,
            Price:       product.Price,
            SellerId:    product.SellerId,
            CreatedAt:   product.CreatedAt.Format(time.RFC3339),
            UpdatedAt:   product.UpdatedAt.Format(time.RFC3339),
        })
    }

    return c.JSON(http.StatusOK, resp)
}

func (ctrl *ProductController) ById(c echo.Context) error {
    id := c.Param("id")
    product, err := ctrl.productService.ById(id)
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
    }

    resp := ProductResponse{
        Id:          product.Id,
        Name:        product.Name,
        Description: product.Description,
        Price:       product.Price,
        SellerId:    product.SellerId,
        CreatedAt:   product.CreatedAt.Format(time.RFC3339),
        UpdatedAt:   product.UpdatedAt.Format(time.RFC3339),
    }

    return c.JSON(http.StatusOK, resp)
}

func (ctrl *ProductController) Update(c echo.Context) error {
    id := c.Param("id")
    var req CreateProductRequest
    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
    }

    cmd := command.UpdateProductCommand{
        Id:          id,
        Name:        req.Name,
        Description: req.Description,
        Price:       req.Price,
    }

    product, err := ctrl.productService.Update(cmd)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
    }

    resp := ProductResponse{
        Id:          product.Id,
        Name:        product.Name,
        Description: product.Description,
        Price:       product.Price,
        SellerId:    product.SellerId,
        CreatedAt:   product.CreatedAt.Format(time.RFC3339),
        UpdatedAt:   product.UpdatedAt.Format(time.RFC3339),
    }

    return c.JSON(http.StatusOK, resp)
}

2.5 アプリケーションの起動設定

  1. cmd/marketplace/main.go にアプリケーションの起動設定を実装します。
package main

import (
    _ "github.com/jinzhu/gorm/dialects/postgres"
    "github.com/labstack/echo/v4"
    "github.com/sklinkert/go-ddd/internal/application/services"
    postgres2 "github.com/sklinkert/go-ddd/internal/infrastructure/db/postgres"
    "github.com/sklinkert/go-ddd/internal/interface/api/rest"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "log"
)

func main() {
    dsn := "host=localhost user=root password=password dbname=mydb port=9920 sslmode=disable TimeZone=Asia/Shanghai"
    port := ":9090"

    gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatalf("Failed to connect to database: %v", err)
    }

    // データベースマイグレーション
    gormDB.AutoMigrate(&postgres2.DBProduct{}, &postgres2.DBSeller{})

    // リポジトリの初期化
    productRepo := postgres2.NewGormProductRepository(gormDB)
    sellerRepo := postgres2.NewGormSellerRepository(gormDB)

    // サービスの初期化
    productService := services.NewProductService(productRepo, sellerRepo)
    sellerService := services.NewSellerService(sellerRepo)

    // Echoサーバーの初期化
    e := echo.New()

    // コントローラーの初期化
    rest.NewProductController(e, productService)
    rest.NewSellerController(e, sellerService)

    // サーバーの起動
    if err := e.Start(port); err != nil {
        log.Fatalf("Failed to start server: %v", err)
    }
}

3. テスト

各レイヤーのコンポーネントに対してユニットテストを作成することが重要です。以下は商品エンティティのテスト例です。

package entities

import (
    "testing"
    "time"
)

func TestProduct_Validate(t *testing.T) {
    tests := []struct {
        name    string
        product Product
        wantErr bool
    }{
        {
            name: "valid product",
            product: Product{
                Id:          "1",
                Name:        "Test Product",
                Description: "Test Description",
                Price:       100.0,
                SellerId:    "1",
                CreatedAt:   time.Now(),
                UpdatedAt:   time.Now(),
            },
            wantErr: false,
        },
        {
            name: "empty name",
            product: Product{
                Id:          "1",
                Name:        "",
                Description: "Test Description",
                Price:       100.0,
                SellerId:    "1",
                CreatedAt:   time.Now(),
                UpdatedAt:   time.Now(),
            },
            wantErr: true,
        },
        {
            name: "zero price",
            product: Product{
                Id:          "1",
                Name:        "Test Product",
                Description: "Test Description",
                Price:       0.0,
                SellerId:    "1",
                CreatedAt:   time.Now(),
                UpdatedAt:   time.Now(),
            },
            wantErr: true,
        },
        {
            name: "empty seller id",
            product: Product{
                Id:          "1",
                Name:        "Test Product",
                Description: "Test Description",
                Price:       100.0,
                SellerId:    "",
                CreatedAt:   time.Now(),
                UpdatedAt:   time.Now(),
            },
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if err := tt.product.Validate(); (err != nil) != tt.wantErr {
                t.Errorf("Product.Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

4. まとめ

この実装手順では、Go-DDDマーケットプレイスアプリケーションの商品管理機能を、ドメイン駆動設計の原則に従って実装する方法を説明しました。各レイヤー(ドメイン、アプリケーション、インフラストラクチャ、インターフェース)の役割と実装方法を示し、クリーンなアーキテクチャを実現しています。

この実装パターンを使用することで、ビジネスロジックを明確に分離し、テスト可能で保守性の高いコードを作成することができます。また、新しい機能の追加や既存機能の変更が容易になります。