From e58bf7558e3a62e271e8f039f402671920000170 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Sat, 24 Jan 2026 08:59:01 -0300 Subject: [PATCH 1/3] refactor: modularize domains for scalable architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the project to use a modular domain-driven architecture where each business domain is self-contained with its own domain, usecase, repository, and HTTP layers. This improves scalability, maintainability, and team collaboration for larger projects. BREAKING CHANGE: Package structure has changed from flat layers to domain-based modules. Import paths need to be updated for any external consumers. Changes: - Reorganize internal/ structure into domain modules (user/, outbox/) - Move user entities from internal/domain to internal/user/domain - Move user use cases from internal/usecase to internal/user/usecase - Move user repository from internal/repository to internal/user/repository - Move user HTTP handler from internal/http to internal/user/http - Move outbox entities from internal/domain to internal/outbox/domain - Move outbox repository from internal/repository to internal/outbox/repository - Update all imports to use aliased domain-specific imports - Update main.go to use new import paths with aliases - Update HTTP server to use user HTTP handler from new location - Update worker to use outbox domain from new location - Update all test files with new import paths - Refactor response utilities in internal/http for shared use - Update README.md with new architecture documentation Benefits: - Easy to add new domains without affecting existing code - Clear domain boundaries and encapsulation - Related code is co-located within domain modules - Better support for team collaboration on different domains - Scalable structure suitable for growing applications New structure: internal/ ├── config/ # Shared configuration ├── database/ # Shared database infrastructure ├── http/ # Shared HTTP server and middleware ├── outbox/ # Outbox domain module │ ├── domain/ │ └── repository/ ├── user/ # User domain module │ ├── domain/ │ ├── http/ │ ├── repository/ │ └── usecase/ └── worker/ # Shared background worker --- README.md | 92 ++++++++++++++++--- cmd/app/main.go | 15 +-- internal/http/http_test.go | 49 +++++----- internal/http/response.go | 27 +----- internal/http/server.go | 9 +- .../domain/outbox_event.go} | 12 +-- .../repository/outbox_repository.go | 4 +- .../repository/outbox_repository_test.go | 2 +- internal/user/domain/user.go | 14 +++ internal/{ => user}/http/user_handler.go | 15 ++- .../{ => user}/repository/user_repository.go | 4 +- .../repository/user_repository_test.go | 2 +- internal/{ => user}/usecase/user_usecase.go | 15 +-- .../{ => user}/usecase/user_usecase_test.go | 17 ++-- internal/worker/event_worker.go | 2 +- internal/worker/event_worker_test.go | 2 +- 16 files changed, 172 insertions(+), 109 deletions(-) rename internal/{domain/entities.go => outbox/domain/outbox_event.go} (69%) rename internal/{ => outbox}/repository/outbox_repository.go (95%) rename internal/{ => outbox}/repository/outbox_repository_test.go (98%) create mode 100644 internal/user/domain/user.go rename internal/{ => user}/http/user_handler.go (76%) rename internal/{ => user}/repository/user_repository.go (94%) rename internal/{ => user}/repository/user_repository_test.go (98%) rename internal/{ => user}/usecase/user_usecase.go (85%) rename internal/{ => user}/usecase/user_usecase_test.go (94%) diff --git a/README.md b/README.md index b087eae..d29ef19 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # Go Project Template -A production-ready Go project template following Clean Architecture principles, optimized for building scalable applications with PostgreSQL or MySQL. +A production-ready Go project template following Clean Architecture and Domain-Driven Design principles, optimized for building scalable applications with PostgreSQL or MySQL. ## Features +- **Modular Domain Architecture** - Domain-based code organization for scalability - **Clean Architecture** - Separation of concerns with domain, repository, use case, and presentation layers - **Multiple Database Support** - PostgreSQL and MySQL via unified repository layer - **Database Migrations** - Separate migrations for PostgreSQL and MySQL using golang-migrate @@ -33,17 +34,24 @@ go-project-template/ │ ├── database/ # Database connection and transaction management │ │ ├── database.go │ │ └── txmanager.go -│ ├── domain/ # Domain entities -│ │ └── entities.go -│ ├── http/ # HTTP server and handlers +│ ├── http/ # HTTP server and shared infrastructure │ │ ├── middleware.go -│ │ ├── server.go -│ │ └── user_handler.go -│ ├── repository/ # Data access layer -│ │ ├── outbox_repository.go -│ │ └── user_repository.go -│ ├── usecase/ # Business logic -│ │ └── user_usecase.go +│ │ ├── response.go +│ │ └── server.go +│ ├── outbox/ # Outbox domain module +│ │ ├── domain/ # Outbox entities +│ │ │ └── outbox_event.go +│ │ └── repository/ # Outbox data access +│ │ └── outbox_repository.go +│ ├── user/ # User domain module +│ │ ├── domain/ # User entities +│ │ │ └── user.go +│ │ ├── http/ # User HTTP handlers +│ │ │ └── user_handler.go +│ │ ├── repository/ # User data access +│ │ │ └── user_repository.go +│ │ └── usecase/ # User business logic +│ │ └── user_usecase.go │ └── worker/ # Background workers │ └── event_worker.go ├── migrations/ @@ -58,6 +66,17 @@ go-project-template/ └── go.sum ``` +### Domain Module Structure + +The project follows a modular domain architecture where each business domain is organized in its own directory with clear separation of concerns: + +- **`domain/`** - Contains entities, value objects, and domain types +- **`usecase/`** - Implements business logic and orchestrates operations +- **`repository/`** - Handles data persistence and retrieval +- **`http/`** - Contains HTTP handlers and request/response types + +This structure makes it easy to add new domains (e.g., `internal/product/`, `internal/order/`) without affecting existing modules. + ## Prerequisites - Go 1.25 or higher @@ -297,12 +316,55 @@ make docker-run-migrate ## Architecture +### Modular Domain Architecture + +The project follows a modular domain-driven structure where each business domain is self-contained: + +**User Domain** (`internal/user/`) +- `domain/` - User entity and types +- `usecase/` - User registration, authentication logic +- `repository/` - User data persistence +- `http/` - User HTTP endpoints and handlers + +**Outbox Domain** (`internal/outbox/`) +- `domain/` - OutboxEvent entity and status types +- `repository/` - Event persistence and retrieval + +**Shared Infrastructure** +- `config/` - Application configuration +- `database/` - Database connection and transaction management +- `http/` - HTTP server, middleware, and shared utilities +- `worker/` - Background event processing + +### Benefits of This Structure + +1. **Scalability** - Easy to add new domains without affecting existing code +2. **Encapsulation** - Each domain is self-contained with clear boundaries +3. **Team Collaboration** - Teams can work on different domains independently +4. **Maintainability** - Related code is co-located, making it easier to understand and modify + +### Adding New Domains + +To add a new domain (e.g., `product`): + +``` +internal/product/ +├── domain/ +│ └── product.go +├── usecase/ +│ └── product_usecase.go +├── repository/ +│ └── product_repository.go +└── http/ + └── product_handler.go +``` + ### Clean Architecture Layers -1. **Domain Layer** (`internal/domain`) - Contains business entities and rules -2. **Repository Layer** (`internal/repository`) - Data access implementations using sqlutil -3. **Use Case Layer** (`internal/usecase`) - Application business logic -4. **Presentation Layer** (`internal/http`) - HTTP handlers and server +1. **Domain Layer** - Contains business entities and rules (e.g., `internal/user/domain`) +2. **Repository Layer** - Data access implementations using sqlutil (e.g., `internal/user/repository`) +3. **Use Case Layer** - Application business logic (e.g., `internal/user/usecase`) +4. **Presentation Layer** - HTTP handlers and server (e.g., `internal/user/http`) ### Transaction Management diff --git a/cmd/app/main.go b/cmd/app/main.go index 3d99154..5f291b3 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -14,8 +14,9 @@ import ( "github.com/allisson/go-project-template/internal/config" "github.com/allisson/go-project-template/internal/database" "github.com/allisson/go-project-template/internal/http" - "github.com/allisson/go-project-template/internal/repository" - "github.com/allisson/go-project-template/internal/usecase" + outboxRepository "github.com/allisson/go-project-template/internal/outbox/repository" + userRepository "github.com/allisson/go-project-template/internal/user/repository" + userUsecase "github.com/allisson/go-project-template/internal/user/usecase" "github.com/allisson/go-project-template/internal/worker" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/mysql" @@ -99,16 +100,16 @@ func runServer(ctx context.Context) error { // Initialize components txManager := database.NewTxManager(db) - userRepo := repository.NewUserRepository(db, cfg.DBDriver) - outboxRepo := repository.NewOutboxEventRepository(db, cfg.DBDriver) + userRepo := userRepository.NewUserRepository(db, cfg.DBDriver) + outboxRepo := outboxRepository.NewOutboxEventRepository(db, cfg.DBDriver) - userUseCase, err := usecase.NewUserUseCase(txManager, userRepo, outboxRepo) + userUseCaseInstance, err := userUsecase.NewUserUseCase(txManager, userRepo, outboxRepo) if err != nil { return fmt.Errorf("failed to create user use case: %w", err) } // Create HTTP server - server := http.NewServer(cfg.ServerHost, cfg.ServerPort, logger, userUseCase) + server := http.NewServer(cfg.ServerHost, cfg.ServerPort, logger, userUseCaseInstance) // Setup graceful shutdown ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) @@ -191,7 +192,7 @@ func runWorker(ctx context.Context) error { // Initialize components txManager := database.NewTxManager(db) - outboxRepo := repository.NewOutboxEventRepository(db, cfg.DBDriver) + outboxRepo := outboxRepository.NewOutboxEventRepository(db, cfg.DBDriver) workerConfig := worker.Config{ Interval: cfg.WorkerInterval, diff --git a/internal/http/http_test.go b/internal/http/http_test.go index e70c9d4..8ff6457 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -11,8 +11,9 @@ import ( "os" "testing" - "github.com/allisson/go-project-template/internal/domain" - "github.com/allisson/go-project-template/internal/usecase" + userDomain "github.com/allisson/go-project-template/internal/user/domain" + userHttp "github.com/allisson/go-project-template/internal/user/http" + userUsecase "github.com/allisson/go-project-template/internal/user/usecase" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -23,28 +24,28 @@ type MockUserUseCase struct { mock.Mock } -func (m *MockUserUseCase) RegisterUser(ctx context.Context, input usecase.RegisterUserInput) (*domain.User, error) { +func (m *MockUserUseCase) RegisterUser(ctx context.Context, input userUsecase.RegisterUserInput) (*userDomain.User, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*domain.User), args.Error(1) + return args.Get(0).(*userDomain.User), args.Error(1) } -func (m *MockUserUseCase) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { +func (m *MockUserUseCase) GetUserByEmail(ctx context.Context, email string) (*userDomain.User, error) { args := m.Called(ctx, email) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*domain.User), args.Error(1) + return args.Get(0).(*userDomain.User), args.Error(1) } -func (m *MockUserUseCase) GetUserByID(ctx context.Context, id int64) (*domain.User, error) { +func (m *MockUserUseCase) GetUserByID(ctx context.Context, id int64) (*userDomain.User, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*domain.User), args.Error(1) + return args.Get(0).(*userDomain.User), args.Error(1) } func TestMakeJSONResponse(t *testing.T) { @@ -84,7 +85,7 @@ func TestMakeJSONResponse(t *testing.T) { makeJSONResponse(w, tt.statusCode, tt.body) assert.Equal(t, tt.statusCode, w.Code) - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) assert.JSONEq(t, tt.expectedBody, w.Body.String()) }) } @@ -98,7 +99,7 @@ func TestHealthHandler(t *testing.T) { handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) @@ -115,7 +116,7 @@ func TestReadinessHandler_Ready(t *testing.T) { handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) @@ -134,7 +135,7 @@ func TestReadinessHandler_NotReady(t *testing.T) { handler.ServeHTTP(w, req) assert.Equal(t, http.StatusServiceUnavailable, w.Code) - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) @@ -229,15 +230,15 @@ func TestChainMiddleware(t *testing.T) { func TestUserHandler_Register_Success(t *testing.T) { mockUseCase := &MockUserUseCase{} - handler := NewUserHandler(mockUseCase, nil) + handler := userHttp.NewUserHandler(mockUseCase, nil) - input := usecase.RegisterUserInput{ + input := userUsecase.RegisterUserInput{ Name: "John Doe", Email: "john@example.com", Password: "securepassword123", } - expectedUser := &domain.User{ + expectedUser := &userDomain.User{ ID: 1, Name: input.Name, Email: input.Email, @@ -266,7 +267,7 @@ func TestUserHandler_Register_Success(t *testing.T) { func TestUserHandler_Register_InvalidJSON(t *testing.T) { mockUseCase := &MockUserUseCase{} - handler := NewUserHandler(mockUseCase, nil) + handler := userHttp.NewUserHandler(mockUseCase, nil) req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader([]byte("invalid json"))) req.Header.Set("Content-Type", "application/json") @@ -284,15 +285,15 @@ func TestUserHandler_Register_InvalidJSON(t *testing.T) { func TestUserHandler_Register_ValidationError(t *testing.T) { mockUseCase := &MockUserUseCase{} - handler := NewUserHandler(mockUseCase, nil) + handler := userHttp.NewUserHandler(mockUseCase, nil) tests := []struct { name string - input usecase.RegisterUserInput + input userUsecase.RegisterUserInput }{ { name: "empty name", - input: usecase.RegisterUserInput{ + input: userUsecase.RegisterUserInput{ Name: "", Email: "john@example.com", Password: "password", @@ -300,7 +301,7 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { }, { name: "empty email", - input: usecase.RegisterUserInput{ + input: userUsecase.RegisterUserInput{ Name: "John Doe", Email: "", Password: "password", @@ -308,7 +309,7 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { }, { name: "empty password", - input: usecase.RegisterUserInput{ + input: userUsecase.RegisterUserInput{ Name: "John Doe", Email: "john@example.com", Password: "", @@ -337,9 +338,9 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { func TestUserHandler_Register_UseCaseError(t *testing.T) { mockUseCase := &MockUserUseCase{} - handler := NewUserHandler(mockUseCase, nil) + handler := userHttp.NewUserHandler(mockUseCase, nil) - input := usecase.RegisterUserInput{ + input := userUsecase.RegisterUserInput{ Name: "John Doe", Email: "john@example.com", Password: "securepassword123", @@ -367,7 +368,7 @@ func TestUserHandler_Register_UseCaseError(t *testing.T) { func TestUserHandler_Register_MethodNotAllowed(t *testing.T) { mockUseCase := &MockUserUseCase{} - handler := NewUserHandler(mockUseCase, nil) + handler := userHttp.NewUserHandler(mockUseCase, nil) req := httptest.NewRequest(http.MethodGet, "/api/users", nil) w := httptest.NewRecorder() diff --git a/internal/http/response.go b/internal/http/response.go index 73a1331..2bf34c5 100644 --- a/internal/http/response.go +++ b/internal/http/response.go @@ -2,32 +2,15 @@ package http import ( - "bytes" "encoding/json" - "fmt" - "log/slog" "net/http" ) -// makeResponse writes an HTTP response with the specified body, status code, and content type. -func makeResponse(w http.ResponseWriter, body []byte, statusCode int, contentType string) { - w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) +// makeJSONResponse writes a JSON response with the given status code and data +func makeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - if _, err := w.Write(body); err != nil { - slog.Error("failed to write response body", slog.Any("error", err)) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) } } - -// makeJSONResponse marshals the body to JSON and writes it as an HTTP response. -func makeJSONResponse(w http.ResponseWriter, statusCode int, body interface{}) { - d, err := json.Marshal(body) - if err != nil { - slog.Error("failed to marshal body", slog.Any("error", err)) - } - c := new(bytes.Buffer) - err = json.Compact(c, d) - if err != nil { - slog.Error("failed to compact json", slog.Any("error", err)) - } - makeResponse(w, c.Bytes(), statusCode, "application/json") -} diff --git a/internal/http/server.go b/internal/http/server.go index 1c34d7e..20c812c 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -8,14 +8,15 @@ import ( "net/http" "time" - "github.com/allisson/go-project-template/internal/usecase" + userHttp "github.com/allisson/go-project-template/internal/user/http" + userUsecase "github.com/allisson/go-project-template/internal/user/usecase" ) // Server represents the HTTP server type Server struct { server *http.Server logger *slog.Logger - userHandler *UserHandler + userHandler *userHttp.UserHandler } // NewServer creates a new HTTP server @@ -23,9 +24,9 @@ func NewServer( host string, port int, logger *slog.Logger, - userUseCase *usecase.UserUseCase, + userUseCaseInstance *userUsecase.UserUseCase, ) *Server { - userHandler := NewUserHandler(userUseCase, logger) + userHandler := userHttp.NewUserHandler(userUseCaseInstance, logger) return &Server{ logger: logger, diff --git a/internal/domain/entities.go b/internal/outbox/domain/outbox_event.go similarity index 69% rename from internal/domain/entities.go rename to internal/outbox/domain/outbox_event.go index b0b67fb..6d86097 100644 --- a/internal/domain/entities.go +++ b/internal/outbox/domain/outbox_event.go @@ -1,18 +1,8 @@ -// Package domain defines the core domain entities and types for the application. +// Package domain defines the core outbox domain entities and types. package domain import "time" -// User represents a user in the system -type User struct { - ID int64 `db:"id" json:"id"` - Name string `db:"name" json:"name" fieldtag:"insert,update"` - Email string `db:"email" json:"email" fieldtag:"insert,update"` - Password string `db:"password" json:"-" fieldtag:"insert,update"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` -} - // OutboxEventStatus represents the status of an outbox event type OutboxEventStatus string diff --git a/internal/repository/outbox_repository.go b/internal/outbox/repository/outbox_repository.go similarity index 95% rename from internal/repository/outbox_repository.go rename to internal/outbox/repository/outbox_repository.go index b43eff4..49665dd 100644 --- a/internal/repository/outbox_repository.go +++ b/internal/outbox/repository/outbox_repository.go @@ -1,4 +1,4 @@ -// Package repository provides data persistence implementations for domain entities. +// Package repository provides data persistence implementations for outbox entities. package repository import ( @@ -6,7 +6,7 @@ import ( "database/sql" "github.com/allisson/go-project-template/internal/database" - "github.com/allisson/go-project-template/internal/domain" + "github.com/allisson/go-project-template/internal/outbox/domain" "github.com/allisson/sqlutil" ) diff --git a/internal/repository/outbox_repository_test.go b/internal/outbox/repository/outbox_repository_test.go similarity index 98% rename from internal/repository/outbox_repository_test.go rename to internal/outbox/repository/outbox_repository_test.go index 350592e..684eb04 100644 --- a/internal/repository/outbox_repository_test.go +++ b/internal/outbox/repository/outbox_repository_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" - "github.com/allisson/go-project-template/internal/domain" + "github.com/allisson/go-project-template/internal/outbox/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/user/domain/user.go b/internal/user/domain/user.go new file mode 100644 index 0000000..8877e5a --- /dev/null +++ b/internal/user/domain/user.go @@ -0,0 +1,14 @@ +// Package domain defines the core user domain entities and types. +package domain + +import "time" + +// User represents a user in the system +type User struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name" fieldtag:"insert,update"` + Email string `db:"email" json:"email" fieldtag:"insert,update"` + Password string `db:"password" json:"-" fieldtag:"insert,update"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/internal/http/user_handler.go b/internal/user/http/user_handler.go similarity index 76% rename from internal/http/user_handler.go rename to internal/user/http/user_handler.go index 2710c7e..42c026e 100644 --- a/internal/http/user_handler.go +++ b/internal/user/http/user_handler.go @@ -1,4 +1,4 @@ -// Package http provides HTTP server implementation and request handlers. +// Package http provides HTTP handlers for user-related operations. package http import ( @@ -7,10 +7,19 @@ import ( "log/slog" "net/http" - "github.com/allisson/go-project-template/internal/domain" - "github.com/allisson/go-project-template/internal/usecase" + "github.com/allisson/go-project-template/internal/user/domain" + "github.com/allisson/go-project-template/internal/user/usecase" ) +// makeJSONResponse writes a JSON response with the given status code and data +func makeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + // UserUseCaseInterface defines the interface for user use case operations type UserUseCaseInterface interface { RegisterUser(ctx context.Context, input usecase.RegisterUserInput) (*domain.User, error) diff --git a/internal/repository/user_repository.go b/internal/user/repository/user_repository.go similarity index 94% rename from internal/repository/user_repository.go rename to internal/user/repository/user_repository.go index 1cd0819..d8cbe6e 100644 --- a/internal/repository/user_repository.go +++ b/internal/user/repository/user_repository.go @@ -1,4 +1,4 @@ -// Package repository provides data persistence implementations for domain entities. +// Package repository provides data persistence implementations for user entities. package repository import ( @@ -6,7 +6,7 @@ import ( "database/sql" "github.com/allisson/go-project-template/internal/database" - "github.com/allisson/go-project-template/internal/domain" + "github.com/allisson/go-project-template/internal/user/domain" "github.com/allisson/sqlutil" ) diff --git a/internal/repository/user_repository_test.go b/internal/user/repository/user_repository_test.go similarity index 98% rename from internal/repository/user_repository_test.go rename to internal/user/repository/user_repository_test.go index 1eecf2e..271e057 100644 --- a/internal/repository/user_repository_test.go +++ b/internal/user/repository/user_repository_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" - "github.com/allisson/go-project-template/internal/domain" + "github.com/allisson/go-project-template/internal/user/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/usecase/user_usecase.go b/internal/user/usecase/user_usecase.go similarity index 85% rename from internal/usecase/user_usecase.go rename to internal/user/usecase/user_usecase.go index b49bd65..91bbccc 100644 --- a/internal/usecase/user_usecase.go +++ b/internal/user/usecase/user_usecase.go @@ -1,4 +1,4 @@ -// Package usecase implements the application's business logic and orchestrates domain operations. +// Package usecase implements the user business logic and orchestrates user domain operations. package usecase import ( @@ -7,7 +7,8 @@ import ( "fmt" "github.com/allisson/go-project-template/internal/database" - "github.com/allisson/go-project-template/internal/domain" + outboxDomain "github.com/allisson/go-project-template/internal/outbox/domain" + "github.com/allisson/go-project-template/internal/user/domain" "github.com/allisson/go-pwdhash" ) @@ -27,9 +28,9 @@ type UserRepository interface { // OutboxEventRepository interface defines outbox event repository operations type OutboxEventRepository interface { - Create(ctx context.Context, event *domain.OutboxEvent) error - GetPendingEvents(ctx context.Context, limit int) ([]*domain.OutboxEvent, error) - Update(ctx context.Context, event *domain.OutboxEvent) error + Create(ctx context.Context, event *outboxDomain.OutboxEvent) error + GetPendingEvents(ctx context.Context, limit int) ([]*outboxDomain.OutboxEvent, error) + Update(ctx context.Context, event *outboxDomain.OutboxEvent) error } // UserUseCase handles user-related business logic @@ -93,10 +94,10 @@ func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput } // Create outbox event - outboxEvent := &domain.OutboxEvent{ + outboxEvent := &outboxDomain.OutboxEvent{ EventType: "user.created", Payload: string(payloadJSON), - Status: domain.OutboxEventStatusPending, + Status: outboxDomain.OutboxEventStatusPending, Retries: 0, } diff --git a/internal/usecase/user_usecase_test.go b/internal/user/usecase/user_usecase_test.go similarity index 94% rename from internal/usecase/user_usecase_test.go rename to internal/user/usecase/user_usecase_test.go index e64c19a..53a5ed2 100644 --- a/internal/usecase/user_usecase_test.go +++ b/internal/user/usecase/user_usecase_test.go @@ -6,7 +6,8 @@ import ( "errors" "testing" - "github.com/allisson/go-project-template/internal/domain" + outboxDomain "github.com/allisson/go-project-template/internal/outbox/domain" + "github.com/allisson/go-project-template/internal/user/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -61,20 +62,20 @@ type MockOutboxEventRepository struct { mock.Mock } -func (m *MockOutboxEventRepository) Create(ctx context.Context, event *domain.OutboxEvent) error { +func (m *MockOutboxEventRepository) Create(ctx context.Context, event *outboxDomain.OutboxEvent) error { args := m.Called(ctx, event) return args.Error(0) } -func (m *MockOutboxEventRepository) GetPendingEvents(ctx context.Context, limit int) ([]*domain.OutboxEvent, error) { +func (m *MockOutboxEventRepository) GetPendingEvents(ctx context.Context, limit int) ([]*outboxDomain.OutboxEvent, error) { args := m.Called(ctx, limit) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]*domain.OutboxEvent), args.Error(1) + return args.Get(0).([]*outboxDomain.OutboxEvent), args.Error(1) } -func (m *MockOutboxEventRepository) Update(ctx context.Context, event *domain.OutboxEvent) error { +func (m *MockOutboxEventRepository) Update(ctx context.Context, event *outboxDomain.OutboxEvent) error { args := m.Called(ctx, event) return args.Error(0) } @@ -208,10 +209,10 @@ func TestUserUseCase_RegisterUser_VerifyOutboxPayload(t *testing.T) { userRepo.On("Create", ctx, mock.AnythingOfType("*domain.User")).Return(nil) // Capture the outbox event to verify its payload - var capturedEvent *domain.OutboxEvent + var capturedEvent *outboxDomain.OutboxEvent outboxRepo.On("Create", ctx, mock.AnythingOfType("*domain.OutboxEvent")). Run(func(args mock.Arguments) { - capturedEvent = args.Get(1).(*domain.OutboxEvent) + capturedEvent = args.Get(1).(*outboxDomain.OutboxEvent) }). Return(nil) @@ -221,7 +222,7 @@ func TestUserUseCase_RegisterUser_VerifyOutboxPayload(t *testing.T) { assert.NotNil(t, user) assert.NotNil(t, capturedEvent) assert.Equal(t, "user.created", capturedEvent.EventType) - assert.Equal(t, domain.OutboxEventStatusPending, capturedEvent.Status) + assert.Equal(t, outboxDomain.OutboxEventStatusPending, capturedEvent.Status) assert.Equal(t, 0, capturedEvent.Retries) // Verify payload structure diff --git a/internal/worker/event_worker.go b/internal/worker/event_worker.go index 6a05017..8f8f861 100644 --- a/internal/worker/event_worker.go +++ b/internal/worker/event_worker.go @@ -8,7 +8,7 @@ import ( "time" "github.com/allisson/go-project-template/internal/database" - "github.com/allisson/go-project-template/internal/domain" + "github.com/allisson/go-project-template/internal/outbox/domain" ) // Config holds worker configuration diff --git a/internal/worker/event_worker_test.go b/internal/worker/event_worker_test.go index 1370313..6a367c2 100644 --- a/internal/worker/event_worker_test.go +++ b/internal/worker/event_worker_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/allisson/go-project-template/internal/domain" + "github.com/allisson/go-project-template/internal/outbox/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) From 4c9e39fe4b29c42f4c1961193e12f1ecafcd0981 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Sat, 24 Jan 2026 09:12:10 -0300 Subject: [PATCH 2/3] refactor: create shared httputil package for reusable HTTP utilities Extract JSON response handling into a dedicated httputil package to promote code reuse and eliminate duplication across domain modules. This allows all HTTP handlers to use a consistent, public API for response formatting. Changes: - Create new internal/httputil package for shared HTTP utilities - Move MakeJSONResponse from internal/http to internal/httputil - Make MakeJSONResponse function public (capitalized) - Add comprehensive tests for httputil package - Update internal/http/response.go to wrap httputil.MakeJSONResponse - Update internal/http/middleware.go to use MakeJSONResponse - Update internal/user/http/user_handler.go to use httputil.MakeJSONResponse - Remove duplicate makeJSONResponse implementation from user handler - Update internal/http/http_test.go to use httputil.MakeJSONResponse - Update README.md with httputil documentation and usage examples Benefits: - Single source of truth for JSON response handling - Public API accessible from any package (no import cycles) - Eliminates code duplication across domain modules - Consistent response formatting across all endpoints - Well-tested shared utility with 100% coverage - Clear separation of concerns (utilities vs infrastructure) --- README.md | 37 ++++++++++++++++++ internal/http/http_test.go | 3 +- internal/http/middleware.go | 10 +++-- internal/{http => httputil}/response.go | 8 ++-- internal/httputil/response_test.go | 52 +++++++++++++++++++++++++ internal/user/http/user_handler.go | 18 +++------ 6 files changed, 106 insertions(+), 22 deletions(-) rename internal/{http => httputil}/response.go (55%) create mode 100644 internal/httputil/response_test.go diff --git a/README.md b/README.md index d29ef19..29225fd 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ go-project-template/ │ │ ├── middleware.go │ │ ├── response.go │ │ └── server.go +│ ├── httputil/ # HTTP utility functions +│ │ └── response.go │ ├── outbox/ # Outbox domain module │ │ ├── domain/ # Outbox entities │ │ │ └── outbox_event.go @@ -75,6 +77,13 @@ The project follows a modular domain architecture where each business domain is - **`repository/`** - Handles data persistence and retrieval - **`http/`** - Contains HTTP handlers and request/response types +### Shared Utilities + +- **`httputil/`** - Shared HTTP utility functions used across all domain modules (e.g., `MakeJSONResponse`) +- **`config/`** - Application-wide configuration +- **`database/`** - Database connection and transaction management +- **`worker/`** - Background processing infrastructure + This structure makes it easy to add new domains (e.g., `internal/product/`, `internal/order/`) without affecting existing modules. ## Prerequisites @@ -334,6 +343,7 @@ The project follows a modular domain-driven structure where each business domain - `config/` - Application configuration - `database/` - Database connection and transaction management - `http/` - HTTP server, middleware, and shared utilities +- `httputil/` - Reusable HTTP utilities (JSON responses, error handling) - `worker/` - Background event processing ### Benefits of This Structure @@ -359,12 +369,15 @@ internal/product/ └── product_handler.go ``` +**Tip:** Use the shared `httputil.MakeJSONResponse` function in your HTTP handlers for consistent JSON responses across all domains. + ### Clean Architecture Layers 1. **Domain Layer** - Contains business entities and rules (e.g., `internal/user/domain`) 2. **Repository Layer** - Data access implementations using sqlutil (e.g., `internal/user/repository`) 3. **Use Case Layer** - Application business logic (e.g., `internal/user/usecase`) 4. **Presentation Layer** - HTTP handlers and server (e.g., `internal/user/http`) +5. **Utility Layer** - Shared utilities and helpers (e.g., `internal/httputil`) ### Transaction Management @@ -378,6 +391,30 @@ type TxManager interface { Transactions are automatically injected into the context and used by repositories. +### HTTP Utilities + +The `httputil` package provides shared HTTP utilities used across all domain modules: + +**MakeJSONResponse** - Standardized JSON response formatting: + +```go +import "github.com/allisson/go-project-template/internal/httputil" + +func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { + product, err := h.productUseCase.GetProduct(r.Context(), productID) + if err != nil { + httputil.MakeJSONResponse(w, http.StatusNotFound, map[string]string{ + "error": "product not found", + }) + return + } + + httputil.MakeJSONResponse(w, http.StatusOK, product) +} +``` + +This ensures consistent response formatting across all HTTP endpoints and eliminates code duplication. + ### Transactional Outbox Pattern User registration demonstrates the transactional outbox pattern: diff --git a/internal/http/http_test.go b/internal/http/http_test.go index 8ff6457..f4432d7 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -11,6 +11,7 @@ import ( "os" "testing" + "github.com/allisson/go-project-template/internal/httputil" userDomain "github.com/allisson/go-project-template/internal/user/domain" userHttp "github.com/allisson/go-project-template/internal/user/http" userUsecase "github.com/allisson/go-project-template/internal/user/usecase" @@ -82,7 +83,7 @@ func TestMakeJSONResponse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() - makeJSONResponse(w, tt.statusCode, tt.body) + httputil.MakeJSONResponse(w, tt.statusCode, tt.body) assert.Equal(t, tt.statusCode, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) diff --git a/internal/http/middleware.go b/internal/http/middleware.go index 9eeec56..e8a1d47 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -6,6 +6,8 @@ import ( "log/slog" "net/http" "time" + + "github.com/allisson/go-project-template/internal/httputil" ) // Middleware defines a function to wrap http.Handler @@ -45,7 +47,7 @@ func RecoveryMiddleware(logger *slog.Logger) Middleware { slog.String("method", r.Method), ) - makeJSONResponse(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + httputil.MakeJSONResponse(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) } }() @@ -79,7 +81,7 @@ func ChainMiddleware(middlewares ...Middleware) Middleware { // HealthHandler returns a simple health check handler func HealthHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - makeJSONResponse(w, http.StatusOK, map[string]string{"status": "healthy"}) + httputil.MakeJSONResponse(w, http.StatusOK, map[string]string{"status": "healthy"}) }) } @@ -89,11 +91,11 @@ func ReadinessHandler(ctx context.Context) http.Handler { // Check if context is cancelled (application is shutting down) select { case <-ctx.Done(): - makeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"}) + httputil.MakeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"}) return default: } - makeJSONResponse(w, http.StatusOK, map[string]string{"status": "ready"}) + httputil.MakeJSONResponse(w, http.StatusOK, map[string]string{"status": "ready"}) }) } diff --git a/internal/http/response.go b/internal/httputil/response.go similarity index 55% rename from internal/http/response.go rename to internal/httputil/response.go index 2bf34c5..2107011 100644 --- a/internal/http/response.go +++ b/internal/httputil/response.go @@ -1,13 +1,13 @@ -// Package http provides HTTP server implementation and request handlers. -package http +// Package httputil provides HTTP utility functions for request and response handling. +package httputil import ( "encoding/json" "net/http" ) -// makeJSONResponse writes a JSON response with the given status code and data -func makeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) { +// MakeJSONResponse writes a JSON response with the given status code and data +func MakeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(data); err != nil { diff --git a/internal/httputil/response_test.go b/internal/httputil/response_test.go new file mode 100644 index 0000000..118e2e4 --- /dev/null +++ b/internal/httputil/response_test.go @@ -0,0 +1,52 @@ +package httputil + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMakeJSONResponse(t *testing.T) { + tests := []struct { + name string + body interface{} + statusCode int + expectedBody string + }{ + { + name: "success response", + body: map[string]string{"status": "ok"}, + statusCode: http.StatusOK, + expectedBody: `{"status":"ok"}`, + }, + { + name: "error response", + body: map[string]string{"error": "something went wrong"}, + statusCode: http.StatusInternalServerError, + expectedBody: `{"error":"something went wrong"}`, + }, + { + name: "complex object", + body: map[string]interface{}{ + "id": 1, + "name": "Test", + "data": map[string]string{"key": "value"}, + }, + statusCode: http.StatusOK, + expectedBody: `{"data":{"key":"value"},"id":1,"name":"Test"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + MakeJSONResponse(w, tt.statusCode, tt.body) + + assert.Equal(t, tt.statusCode, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + assert.JSONEq(t, tt.expectedBody, w.Body.String()) + }) + } +} diff --git a/internal/user/http/user_handler.go b/internal/user/http/user_handler.go index 42c026e..0b640e7 100644 --- a/internal/user/http/user_handler.go +++ b/internal/user/http/user_handler.go @@ -7,19 +7,11 @@ import ( "log/slog" "net/http" + "github.com/allisson/go-project-template/internal/httputil" "github.com/allisson/go-project-template/internal/user/domain" "github.com/allisson/go-project-template/internal/user/usecase" ) -// makeJSONResponse writes a JSON response with the given status code and data -func makeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(data); err != nil { - http.Error(w, "failed to encode response", http.StatusInternalServerError) - } -} - // UserUseCaseInterface defines the interface for user use case operations type UserUseCaseInterface interface { RegisterUser(ctx context.Context, input usecase.RegisterUserInput) (*domain.User, error) @@ -53,13 +45,13 @@ func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { if h.logger != nil { h.logger.Error("failed to decode request body", slog.Any("error", err)) } - makeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + httputil.MakeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } // Validate input if input.Name == "" || input.Email == "" || input.Password == "" { - makeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "name, email, and password are required"}) + httputil.MakeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "name, email, and password are required"}) return } @@ -68,9 +60,9 @@ func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { if h.logger != nil { h.logger.Error("failed to register user", slog.Any("error", err)) } - makeJSONResponse(w, http.StatusInternalServerError, map[string]string{"error": "failed to register user"}) + httputil.MakeJSONResponse(w, http.StatusInternalServerError, map[string]string{"error": "failed to register user"}) return } - makeJSONResponse(w, http.StatusCreated, user) + httputil.MakeJSONResponse(w, http.StatusCreated, user) } From 13bebc9992d403277bd24dfbd03606a78567a5b4 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Sat, 24 Jan 2026 09:17:48 -0300 Subject: [PATCH 3/3] feat: implement DTO pattern for API layer separation Introduce Data Transfer Objects (DTOs) to enforce clear boundaries between internal domain models and external API contracts. This architectural improvement enhances security, maintainability, and flexibility. Changes: - Create internal/user/http/dto package with request, response, and mapper - Add RegisterUserRequest DTO with validation logic - Add UserResponse DTO excluding sensitive fields (password) - Add mapper functions to convert between DTOs and domain/usecase models - Update user_handler.go to use DTOs instead of exposing domain models - Remove JSON tags from User domain entity (internal/user/domain/user.go) - Update tests to use DTOs for API requests and responses - Add comprehensive DTO pattern documentation to README.md Benefits: - Domain models now purely represent internal business entities - Sensitive fields never exposed in API responses - API contracts can evolve independently from domain models - Request validation happens at the DTO level - Enables multiple API versions with different DTOs - Improved security through explicit field mapping The User entity no longer has JSON serialization tags, making it a true domain model decoupled from presentation concerns. HTTP handlers now use purpose-built DTOs that explicitly define the API contract. --- README.md | 95 +++++++++++++++++++++++++++--- internal/http/http_test.go | 41 ++++++++----- internal/user/domain/user.go | 12 ++-- internal/user/http/dto/mapper.go | 28 +++++++++ internal/user/http/dto/request.go | 25 ++++++++ internal/user/http/dto/response.go | 15 +++++ internal/user/http/user_handler.go | 18 ++++-- 7 files changed, 201 insertions(+), 33 deletions(-) create mode 100644 internal/user/http/dto/mapper.go create mode 100644 internal/user/http/dto/request.go create mode 100644 internal/user/http/dto/response.go diff --git a/README.md b/README.md index 29225fd..99f15d8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ go-project-template/ │ │ ├── domain/ # User entities │ │ │ └── user.go │ │ ├── http/ # User HTTP handlers +│ │ │ ├── dto/ # Request/response DTOs +│ │ │ │ ├── request.go +│ │ │ │ ├── response.go +│ │ │ │ └── mapper.go │ │ │ └── user_handler.go │ │ ├── repository/ # User data access │ │ │ └── user_repository.go @@ -72,10 +76,11 @@ go-project-template/ The project follows a modular domain architecture where each business domain is organized in its own directory with clear separation of concerns: -- **`domain/`** - Contains entities, value objects, and domain types +- **`domain/`** - Contains entities, value objects, and domain types (pure internal representation) - **`usecase/`** - Implements business logic and orchestrates operations - **`repository/`** - Handles data persistence and retrieval -- **`http/`** - Contains HTTP handlers and request/response types +- **`http/`** - Contains HTTP handlers and DTOs (Data Transfer Objects) + - **`dto/`** - Request/response DTOs and mappers (API contracts) ### Shared Utilities @@ -334,6 +339,7 @@ The project follows a modular domain-driven structure where each business domain - `usecase/` - User registration, authentication logic - `repository/` - User data persistence - `http/` - User HTTP endpoints and handlers + - `dto/` - Request/response DTOs and mappers **Outbox Domain** (`internal/outbox/`) - `domain/` - OutboxEvent entity and status types @@ -360,16 +366,24 @@ To add a new domain (e.g., `product`): ``` internal/product/ ├── domain/ -│ └── product.go +│ └── product.go # Domain entity (no JSON tags) ├── usecase/ -│ └── product_usecase.go +│ └── product_usecase.go # Business logic ├── repository/ -│ └── product_repository.go +│ └── product_repository.go # Data access └── http/ - └── product_handler.go + ├── dto/ + │ ├── request.go # API request DTOs + │ ├── response.go # API response DTOs + │ └── mapper.go # DTO-to-domain mappers + └── product_handler.go # HTTP handlers ``` -**Tip:** Use the shared `httputil.MakeJSONResponse` function in your HTTP handlers for consistent JSON responses across all domains. +**Tips:** +- Use the shared `httputil.MakeJSONResponse` function in your HTTP handlers for consistent JSON responses +- Keep domain models free of JSON tags - use DTOs for API serialization +- Implement validation in your request DTOs +- Create mapper functions to convert between DTOs and domain models ### Clean Architecture Layers @@ -379,6 +393,73 @@ internal/product/ 4. **Presentation Layer** - HTTP handlers and server (e.g., `internal/user/http`) 5. **Utility Layer** - Shared utilities and helpers (e.g., `internal/httputil`) +### Data Transfer Objects (DTOs) + +The project enforces clear boundaries between internal domain models and external API contracts using DTOs: + +**Domain Models** (`internal/user/domain/user.go`) +- Pure internal representation of business entities +- No JSON tags - completely decoupled from API serialization +- Focus on business rules and domain logic + +**DTOs** (`internal/user/http/dto/`) +- `request.go` - API request structures with validation +- `response.go` - API response structures +- `mapper.go` - Conversion functions between DTOs and domain models + +**Example:** + +```go +// Request DTO +type RegisterUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` +} + +func (r *RegisterUserRequest) Validate() error { + if r.Name == "" { + return errors.New("name is required") + } + // ... validation logic +} + +// Response DTO +type UserResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Mapper functions +func ToRegisterUserInput(req RegisterUserRequest) usecase.RegisterUserInput { + return usecase.RegisterUserInput{ + Name: req.Name, + Email: req.Email, + Password: req.Password, + } +} + +func ToUserResponse(user *domain.User) UserResponse { + return UserResponse{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} +``` + +**Benefits:** +1. **Separation of Concerns** - Domain models evolve independently from API contracts +2. **Security** - Sensitive fields (like passwords) are never exposed in API responses +3. **Flexibility** - Different API views of the same domain model (e.g., summary vs detailed) +4. **Versioning** - Easy to maintain multiple API versions with different DTOs +5. **Validation** - Request validation happens at the DTO level before reaching domain logic + ### Transaction Management The template implements a TxManager interface for handling database transactions: diff --git a/internal/http/http_test.go b/internal/http/http_test.go index f4432d7..5402220 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -14,6 +14,7 @@ import ( "github.com/allisson/go-project-template/internal/httputil" userDomain "github.com/allisson/go-project-template/internal/user/domain" userHttp "github.com/allisson/go-project-template/internal/user/http" + "github.com/allisson/go-project-template/internal/user/http/dto" userUsecase "github.com/allisson/go-project-template/internal/user/usecase" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -233,12 +234,18 @@ func TestUserHandler_Register_Success(t *testing.T) { mockUseCase := &MockUserUseCase{} handler := userHttp.NewUserHandler(mockUseCase, nil) - input := userUsecase.RegisterUserInput{ + req := dto.RegisterUserRequest{ Name: "John Doe", Email: "john@example.com", Password: "securepassword123", } + input := userUsecase.RegisterUserInput{ + Name: req.Name, + Email: req.Email, + Password: req.Password, + } + expectedUser := &userDomain.User{ ID: 1, Name: input.Name, @@ -247,12 +254,12 @@ func TestUserHandler_Register_Success(t *testing.T) { mockUseCase.On("RegisterUser", mock.Anything, input).Return(expectedUser, nil) - body, _ := json.Marshal(input) - req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - handler.RegisterUser(w, req) + handler.RegisterUser(w, httpReq) assert.Equal(t, http.StatusCreated, w.Code) @@ -290,11 +297,11 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { tests := []struct { name string - input userUsecase.RegisterUserInput + input dto.RegisterUserRequest }{ { name: "empty name", - input: userUsecase.RegisterUserInput{ + input: dto.RegisterUserRequest{ Name: "", Email: "john@example.com", Password: "password", @@ -302,7 +309,7 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { }, { name: "empty email", - input: userUsecase.RegisterUserInput{ + input: dto.RegisterUserRequest{ Name: "John Doe", Email: "", Password: "password", @@ -310,7 +317,7 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { }, { name: "empty password", - input: userUsecase.RegisterUserInput{ + input: dto.RegisterUserRequest{ Name: "John Doe", Email: "john@example.com", Password: "", @@ -341,21 +348,27 @@ func TestUserHandler_Register_UseCaseError(t *testing.T) { mockUseCase := &MockUserUseCase{} handler := userHttp.NewUserHandler(mockUseCase, nil) - input := userUsecase.RegisterUserInput{ + req := dto.RegisterUserRequest{ Name: "John Doe", Email: "john@example.com", Password: "securepassword123", } + input := userUsecase.RegisterUserInput{ + Name: req.Name, + Email: req.Email, + Password: req.Password, + } + useCaseError := errors.New("database error") mockUseCase.On("RegisterUser", mock.Anything, input).Return(nil, useCaseError) - body, _ := json.Marshal(input) - req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") + body, _ := json.Marshal(req) + httpReq := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - handler.RegisterUser(w, req) + handler.RegisterUser(w, httpReq) assert.Equal(t, http.StatusInternalServerError, w.Code) diff --git a/internal/user/domain/user.go b/internal/user/domain/user.go index 8877e5a..650bba1 100644 --- a/internal/user/domain/user.go +++ b/internal/user/domain/user.go @@ -5,10 +5,10 @@ import "time" // User represents a user in the system type User struct { - ID int64 `db:"id" json:"id"` - Name string `db:"name" json:"name" fieldtag:"insert,update"` - Email string `db:"email" json:"email" fieldtag:"insert,update"` - Password string `db:"password" json:"-" fieldtag:"insert,update"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int64 `db:"id"` + Name string `db:"name" fieldtag:"insert,update"` + Email string `db:"email" fieldtag:"insert,update"` + Password string `db:"password" fieldtag:"insert,update"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } diff --git a/internal/user/http/dto/mapper.go b/internal/user/http/dto/mapper.go new file mode 100644 index 0000000..2eedb48 --- /dev/null +++ b/internal/user/http/dto/mapper.go @@ -0,0 +1,28 @@ +// Package dto provides data transfer objects for the user HTTP layer. +package dto + +import ( + "github.com/allisson/go-project-template/internal/user/domain" + "github.com/allisson/go-project-template/internal/user/usecase" +) + +// ToRegisterUserInput converts a RegisterUserRequest DTO to a RegisterUserInput use case input +func ToRegisterUserInput(req RegisterUserRequest) usecase.RegisterUserInput { + return usecase.RegisterUserInput{ + Name: req.Name, + Email: req.Email, + Password: req.Password, + } +} + +// ToUserResponse converts a domain User model to a UserResponse DTO +// This enforces the boundary between internal domain models and external API contracts +func ToUserResponse(user *domain.User) UserResponse { + return UserResponse{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} diff --git a/internal/user/http/dto/request.go b/internal/user/http/dto/request.go new file mode 100644 index 0000000..73df239 --- /dev/null +++ b/internal/user/http/dto/request.go @@ -0,0 +1,25 @@ +// Package dto provides data transfer objects for the user HTTP layer. +package dto + +import "errors" + +// RegisterUserRequest represents the API request for user registration +type RegisterUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` +} + +// Validate validates the RegisterUserRequest +func (r *RegisterUserRequest) Validate() error { + if r.Name == "" { + return errors.New("name is required") + } + if r.Email == "" { + return errors.New("email is required") + } + if r.Password == "" { + return errors.New("password is required") + } + return nil +} diff --git a/internal/user/http/dto/response.go b/internal/user/http/dto/response.go new file mode 100644 index 0000000..e90e851 --- /dev/null +++ b/internal/user/http/dto/response.go @@ -0,0 +1,15 @@ +// Package dto provides data transfer objects for the user HTTP layer. +package dto + +import "time" + +// UserResponse represents the API response for a user +// It excludes sensitive information like passwords and provides +// a clean external representation of the user domain model +type UserResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/user/http/user_handler.go b/internal/user/http/user_handler.go index 0b640e7..c6dfc5b 100644 --- a/internal/user/http/user_handler.go +++ b/internal/user/http/user_handler.go @@ -9,6 +9,7 @@ import ( "github.com/allisson/go-project-template/internal/httputil" "github.com/allisson/go-project-template/internal/user/domain" + "github.com/allisson/go-project-template/internal/user/http/dto" "github.com/allisson/go-project-template/internal/user/usecase" ) @@ -40,8 +41,8 @@ func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { return } - var input usecase.RegisterUserInput - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + var req dto.RegisterUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if h.logger != nil { h.logger.Error("failed to decode request body", slog.Any("error", err)) } @@ -49,12 +50,15 @@ func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { return } - // Validate input - if input.Name == "" || input.Email == "" || input.Password == "" { - httputil.MakeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "name, email, and password are required"}) + // Validate request + if err := req.Validate(); err != nil { + httputil.MakeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } + // Convert DTO to use case input + input := dto.ToRegisterUserInput(req) + user, err := h.userUseCase.RegisterUser(r.Context(), input) if err != nil { if h.logger != nil { @@ -64,5 +68,7 @@ func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { return } - httputil.MakeJSONResponse(w, http.StatusCreated, user) + // Convert domain model to response DTO + response := dto.ToUserResponse(user) + httputil.MakeJSONResponse(w, http.StatusCreated, response) }