Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A production-ready Go project template following Clean Architecture and Domain-D
- **Dependency Injection Container** - Centralized component wiring with lazy initialization and clean resource management
- **Multiple Database Support** - PostgreSQL and MySQL via unified repository layer
- **Database Migrations** - Separate migrations for PostgreSQL and MySQL using golang-migrate
- **UUIDv7 Primary Keys** - Time-ordered, sortable UUIDs for globally unique identifiers
- **Transaction Management** - TxManager interface for handling database transactions
- **Transactional Outbox Pattern** - Event-driven architecture with guaranteed delivery
- **HTTP Server** - Standard library HTTP server with middleware for logging and panic recovery
Expand Down Expand Up @@ -172,6 +173,68 @@ formatters:

This ensures the linter correctly groups your local imports.

### UUIDv7 Primary Keys

The project uses **UUIDv7** for all primary keys instead of auto-incrementing integers. UUIDv7 provides several advantages:

**Benefits:**
- **Time-ordered**: UUIDs include timestamp information, maintaining temporal ordering
- **Globally unique**: No collision risk across distributed systems or databases
- **Database friendly**: Better index performance than random UUIDs (v4) due to sequential nature
- **Scalability**: No need for centralized ID generation or coordination
- **Merge-friendly**: Databases can be merged without ID conflicts

**Implementation:**

All ID fields use `uuid.UUID` type from `github.com/google/uuid`:

```go
import "github.com/google/uuid"

type User struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name"`
Email string `db:"email"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
```

IDs are generated in the application code using `uuid.NewV7()`:

```go
user := &domain.User{
ID: uuid.Must(uuid.NewV7()),
Name: input.Name,
Email: input.Email,
Password: hashedPassword,
}
```

**Database Storage:**
- **PostgreSQL**: `UUID` type (native support)
- **MySQL**: `BINARY(16)` type (16-byte storage)

**Migration Example (PostgreSQL):**
```sql
CREATE TABLE users (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```

**Migration Example (MySQL):**
```sql
CREATE TABLE users (
id BINARY(16) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 3. Install dependencies

```bash
Expand Down Expand Up @@ -576,10 +639,11 @@ package domain
import (
"time"
apperrors "github.com/yourname/yourproject/internal/errors"
"github.com/google/uuid"
)

type Product struct {
ID int64
ID uuid.UUID
Name string
Price float64
Stock int
Expand Down Expand Up @@ -725,7 +789,7 @@ func (r *RegisterUserRequest) Validate() error {

// Response DTO
type UserResponse struct {
ID int64 `json:"id"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
Expand Down Expand Up @@ -977,6 +1041,7 @@ go tool cover -html=coverage.out
- [go-pwdhash](https://github.com/allisson/go-pwdhash) - Password hashing with Argon2id
- [sqlutil](https://github.com/allisson/sqlutil) - SQL utilities for unified database access
- [validation](https://github.com/jellydator/validation) - Advanced input validation library
- [uuid](https://github.com/google/uuid) - UUID generation including UUIDv7 support
- [urfave/cli](https://github.com/urfave/cli) - CLI framework
- [golang-migrate](https://github.com/golang-migrate/migrate) - Database migrations

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/allisson/sqlutil v1.10.0
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jellydator/validation v1.2.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
Expand Down
8 changes: 5 additions & 3 deletions internal/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
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/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
Expand All @@ -42,7 +43,7 @@ func (m *MockUserUseCase) GetUserByEmail(ctx context.Context, email string) (*us
return args.Get(0).(*userDomain.User), args.Error(1)
}

func (m *MockUserUseCase) GetUserByID(ctx context.Context, id int64) (*userDomain.User, error) {
func (m *MockUserUseCase) GetUserByID(ctx context.Context, id uuid.UUID) (*userDomain.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
Expand Down Expand Up @@ -246,8 +247,9 @@ func TestUserHandler_Register_Success(t *testing.T) {
Password: req.Password,
}

uuid1 := uuid.Must(uuid.NewV7())
expectedUser := &userDomain.User{
ID: 1,
ID: uuid1,
Name: input.Name,
Email: input.Email,
}
Expand All @@ -266,7 +268,7 @@ func TestUserHandler_Register_Success(t *testing.T) {
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(1), response["id"])
assert.Equal(t, uuid1.String(), response["id"])
assert.Equal(t, input.Name, response["name"])
assert.Equal(t, input.Email, response["email"])

Expand Down
8 changes: 6 additions & 2 deletions internal/outbox/domain/outbox_event.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Package domain defines the core outbox domain entities and types.
package domain

import "time"
import (
"time"

"github.com/google/uuid"
)

// OutboxEventStatus represents the status of an outbox event
type OutboxEventStatus string
Expand All @@ -14,7 +18,7 @@ const (

// OutboxEvent represents an event in the transactional outbox pattern
type OutboxEvent struct {
ID int64 `db:"id" json:"id"`
ID uuid.UUID `db:"id" json:"id"`
EventType string `db:"event_type" json:"event_type" fieldtag:"insert,update"`
Payload string `db:"payload" json:"payload" fieldtag:"insert,update"`
Status OutboxEventStatus `db:"status" json:"status" fieldtag:"insert,update"`
Expand Down
13 changes: 9 additions & 4 deletions internal/outbox/repository/outbox_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/DATA-DOG/go-sqlmock"
"github.com/allisson/go-project-template/internal/outbox/domain"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -72,9 +73,11 @@ func TestOutboxEventRepository_GetPendingEvents(t *testing.T) {
ctx := context.Background()

now := time.Now()
uuid1 := uuid.Must(uuid.NewV7())
uuid2 := uuid.Must(uuid.NewV7())
expectedEvents := []*domain.OutboxEvent{
{
ID: 1,
ID: uuid1,
EventType: "user.created",
Payload: `{"id": 1}`,
Status: domain.OutboxEventStatusPending,
Expand All @@ -83,7 +86,7 @@ func TestOutboxEventRepository_GetPendingEvents(t *testing.T) {
UpdatedAt: now,
},
{
ID: 2,
ID: uuid2,
EventType: "user.created",
Payload: `{"id": 2}`,
Status: domain.OutboxEventStatusPending,
Expand Down Expand Up @@ -141,8 +144,9 @@ func TestOutboxEventRepository_Update(t *testing.T) {
ctx := context.Background()

now := time.Now()
uuid1 := uuid.Must(uuid.NewV7())
event := &domain.OutboxEvent{
ID: 1,
ID: uuid1,
EventType: "user.created",
Payload: `{"id": 1}`,
Status: domain.OutboxEventStatusProcessed,
Expand All @@ -169,8 +173,9 @@ func TestOutboxEventRepository_Update_Error(t *testing.T) {
repo := NewOutboxEventRepository(db, "postgres")
ctx := context.Background()

uuid1 := uuid.Must(uuid.NewV7())
event := &domain.OutboxEvent{
ID: 999,
ID: uuid1,
EventType: "user.created",
Payload: `{"id": 1}`,
Status: domain.OutboxEventStatusProcessed,
Expand Down
3 changes: 2 additions & 1 deletion internal/user/domain/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import (
"time"

"github.com/allisson/go-project-template/internal/errors"
"github.com/google/uuid"
)

// User represents a user in the system
type User struct {
ID int64 `db:"id"`
ID uuid.UUID `db:"id"`
Name string `db:"name" fieldtag:"insert,update"`
Email string `db:"email" fieldtag:"insert,update"`
Password string `db:"password" fieldtag:"insert,update"`
Expand Down
8 changes: 6 additions & 2 deletions internal/user/http/dto/response.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// Package dto provides data transfer objects for the user HTTP layer.
package dto

import "time"
import (
"time"

"github.com/google/uuid"
)

// 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"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
Expand Down
3 changes: 2 additions & 1 deletion internal/user/repository/user_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/allisson/go-project-template/internal/database"
"github.com/allisson/go-project-template/internal/user/domain"
"github.com/allisson/sqlutil"
"github.com/google/uuid"

apperrors "github.com/allisson/go-project-template/internal/errors"
)
Expand Down Expand Up @@ -46,7 +47,7 @@ func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
}

// GetByID retrieves a user by ID
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*domain.User, error) {
func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
var user domain.User
opts := sqlutil.NewFindOptions(r.flavor).WithFilter("id", id)
querier := database.GetTx(ctx, r.db)
Expand Down
16 changes: 10 additions & 6 deletions internal/user/repository/user_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
apperrors "github.com/allisson/go-project-template/internal/errors"
"github.com/allisson/go-project-template/internal/user/domain"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -72,8 +73,9 @@ func TestUserRepository_GetByID(t *testing.T) {
repo := NewUserRepository(db, "postgres")
ctx := context.Background()

uuid1 := uuid.Must(uuid.NewV7())
expectedUser := &domain.User{
ID: 1,
ID: uuid1,
Name: "John Doe",
Email: "john@example.com",
Password: "hashed_password",
Expand All @@ -85,10 +87,10 @@ func TestUserRepository_GetByID(t *testing.T) {
AddRow(expectedUser.ID, expectedUser.Name, expectedUser.Email, expectedUser.Password, expectedUser.CreatedAt, expectedUser.UpdatedAt)

mock.ExpectQuery("SELECT (.+) FROM users").
WithArgs(int64(1)).
WithArgs(uuid1).
WillReturnRows(rows)

user, err := repo.GetByID(ctx, 1)
user, err := repo.GetByID(ctx, uuid1)
assert.NoError(t, err)
assert.NotNil(t, user)
assert.Equal(t, expectedUser.ID, user.ID)
Expand All @@ -105,11 +107,12 @@ func TestUserRepository_GetByID_NotFound(t *testing.T) {
repo := NewUserRepository(db, "postgres")
ctx := context.Background()

notFoundUUID := uuid.Must(uuid.NewV7())
mock.ExpectQuery("SELECT (.+) FROM users").
WithArgs(int64(999)).
WithArgs(notFoundUUID).
WillReturnError(sql.ErrNoRows)

user, err := repo.GetByID(ctx, 999)
user, err := repo.GetByID(ctx, notFoundUUID)
assert.Error(t, err)
assert.Nil(t, user)
assert.True(t, apperrors.Is(err, domain.ErrUserNotFound))
Expand All @@ -124,8 +127,9 @@ func TestUserRepository_GetByEmail(t *testing.T) {
repo := NewUserRepository(db, "postgres")
ctx := context.Background()

uuid1 := uuid.Must(uuid.NewV7())
expectedUser := &domain.User{
ID: 1,
ID: uuid1,
Name: "John Doe",
Email: "john@example.com",
Password: "hashed_password",
Expand Down
9 changes: 6 additions & 3 deletions internal/user/usecase/user_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/allisson/go-project-template/internal/user/domain"
appValidation "github.com/allisson/go-project-template/internal/validation"
"github.com/allisson/go-pwdhash"
"github.com/google/uuid"
)

// RegisterUserInput contains the input data for user registration
Expand All @@ -27,13 +28,13 @@ type RegisterUserInput struct {
type UseCase interface {
RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error)
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
GetUserByID(ctx context.Context, id int64) (*domain.User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
}

// UserRepository interface defines user repository operations
type UserRepository interface {
Create(ctx context.Context, user *domain.User) error
GetByID(ctx context.Context, id int64) (*domain.User, error)
GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
}

Expand Down Expand Up @@ -119,6 +120,7 @@ func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput
}

user := &domain.User{
ID: uuid.Must(uuid.NewV7()),
Name: strings.TrimSpace(input.Name),
Email: strings.TrimSpace(strings.ToLower(input.Email)),
Password: hashedPassword,
Expand All @@ -144,6 +146,7 @@ func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput

// Create outbox event
outboxEvent := &outboxDomain.OutboxEvent{
ID: uuid.Must(uuid.NewV7()),
EventType: "user.created",
Payload: string(payloadJSON),
Status: outboxDomain.OutboxEventStatusPending,
Expand All @@ -170,6 +173,6 @@ func (uc *UserUseCase) GetUserByEmail(ctx context.Context, email string) (*domai
}

// GetUserByID retrieves a user by ID
func (uc *UserUseCase) GetUserByID(ctx context.Context, id int64) (*domain.User, error) {
func (uc *UserUseCase) GetUserByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
return uc.userRepo.GetByID(ctx, id)
}
Loading