diff --git a/README.md b/README.md index b087eae..99f15d8 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,30 @@ 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 +│ ├── httputil/ # HTTP utility functions +│ │ └── response.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 +│ │ │ ├── dto/ # Request/response DTOs +│ │ │ │ ├── request.go +│ │ │ │ ├── response.go +│ │ │ │ └── mapper.go +│ │ │ └── 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 +72,25 @@ 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 (pure internal representation) +- **`usecase/`** - Implements business logic and orchestrates operations +- **`repository/`** - Handles data persistence and retrieval +- **`http/`** - Contains HTTP handlers and DTOs (Data Transfer Objects) + - **`dto/`** - Request/response DTOs and mappers (API contracts) + +### 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 - Go 1.25 or higher @@ -297,12 +330,135 @@ 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 + - `dto/` - Request/response DTOs and mappers + +**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 +- `httputil/` - Reusable HTTP utilities (JSON responses, error handling) +- `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 # Domain entity (no JSON tags) +├── usecase/ +│ └── product_usecase.go # Business logic +├── repository/ +│ └── product_repository.go # Data access +└── http/ + ├── dto/ + │ ├── request.go # API request DTOs + │ ├── response.go # API response DTOs + │ └── mapper.go # DTO-to-domain mappers + └── product_handler.go # HTTP handlers +``` + +**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 -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`) +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 @@ -316,6 +472,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/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..5402220 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -11,8 +11,11 @@ import ( "os" "testing" - "github.com/allisson/go-project-template/internal/domain" - "github.com/allisson/go-project-template/internal/usecase" + "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" "github.com/stretchr/testify/require" @@ -23,28 +26,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) { @@ -81,10 +84,10 @@ 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; 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 +101,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 +118,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 +137,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 +232,21 @@ 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{ + req := dto.RegisterUserRequest{ Name: "John Doe", Email: "john@example.com", Password: "securepassword123", } - expectedUser := &domain.User{ + input := userUsecase.RegisterUserInput{ + Name: req.Name, + Email: req.Email, + Password: req.Password, + } + + expectedUser := &userDomain.User{ ID: 1, Name: input.Name, Email: input.Email, @@ -245,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) @@ -266,7 +275,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 +293,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 dto.RegisterUserRequest }{ { name: "empty name", - input: usecase.RegisterUserInput{ + input: dto.RegisterUserRequest{ Name: "", Email: "john@example.com", Password: "password", @@ -300,7 +309,7 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { }, { name: "empty email", - input: usecase.RegisterUserInput{ + input: dto.RegisterUserRequest{ Name: "John Doe", Email: "", Password: "password", @@ -308,7 +317,7 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { }, { name: "empty password", - input: usecase.RegisterUserInput{ + input: dto.RegisterUserRequest{ Name: "John Doe", Email: "john@example.com", Password: "", @@ -337,23 +346,29 @@ 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{ + 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) @@ -367,7 +382,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/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/http/response.go deleted file mode 100644 index 73a1331..0000000 --- a/internal/http/response.go +++ /dev/null @@ -1,33 +0,0 @@ -// Package http provides HTTP server implementation and request handlers. -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)) - w.WriteHeader(statusCode) - if _, err := w.Write(body); err != nil { - slog.Error("failed to write response body", slog.Any("error", err)) - } -} - -// 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/httputil/response.go b/internal/httputil/response.go new file mode 100644 index 0000000..2107011 --- /dev/null +++ b/internal/httputil/response.go @@ -0,0 +1,16 @@ +// 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{}) { + 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) + } +} 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/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..650bba1 --- /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"` + 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/http/user_handler.go b/internal/user/http/user_handler.go similarity index 56% rename from internal/http/user_handler.go rename to internal/user/http/user_handler.go index 2710c7e..c6dfc5b 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,8 +7,10 @@ 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/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" ) // UserUseCaseInterface defines the interface for user use case operations @@ -39,29 +41,34 @@ 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)) } - 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"}) + // 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 { 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) + // Convert domain model to response DTO + response := dto.ToUserResponse(user) + httputil.MakeJSONResponse(w, http.StatusCreated, response) } 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" )