Skip to content

Project Structure

How to structure a Spine project.

my-app/
├── main.go                  # App entry point
├── go.mod
├── go.sum

├── controller/              # Controller layer
│   └── user_controller.go

├── service/                 # Service layer (Business logic)
│   └── user_service.go

├── repository/              # Repository layer (Data access)
│   └── user_repository.go

├── entity/                  # Database entities
│   └── user.go

├── dto/                     # Request/Response objects
│   ├── user_request.go
│   └── user_response.go

├── routes/                  # Route definitions
│   └── user_routes.go

├── interceptor/             # Interceptors
│   ├── tx_interceptor.go
│   └── logging_interceptor.go

└── migrations/              # DB migrations
    ├── 001_create_users.up.sql
    └── 001_create_users.down.sql

Role of Each Layer

main.go

Entry point of the app. Performs constructor registration, interceptor setup, and route registration.

go
package main

func main() {
    app := spine.New()

    // 1. Register Constructors
    app.Constructor(
        NewDB,
        repository.NewUserRepository,
        service.NewUserService,
        controller.NewUserController,
        interceptor.NewTxInterceptor,
    )

    // 2. Register Interceptors
    app.Interceptor(
        (*interceptor.TxInterceptor)(nil),
        &interceptor.LoggingInterceptor{},
    )

    // 3. Register Routes
    routes.RegisterUserRoutes(app)

    // 4. Start Server
    app.Run(":8080")
}

controller/

Receives HTTP requests and delegates to services. Does not contain business logic.

go
// controller/user_controller.go
package controller

type UserController struct {
    svc *service.UserService  // Service dependency
}

func NewUserController(svc *service.UserService) *UserController {
    return &UserController{svc: svc}
}

// Function signature is the API spec
func (c *UserController) GetUser(
    ctx context.Context,
    q query.Values,
) (dto.UserResponse, error) {
    id := int(q.Int("id", 0))
    return c.svc.Get(ctx, id)
}

func (c *UserController) CreateUser(
    ctx context.Context,
    req dto.CreateUserRequest,
) (dto.UserResponse, error) {
    return c.svc.Create(ctx, req.Name, req.Email)
}

service/

Handles business logic. Accesses data via repositories.

go
// service/user_service.go
package service

type UserService struct {
    repo *repository.UserRepository  // Repository dependency
}

func NewUserService(repo *repository.UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) Get(ctx context.Context, id int) (dto.UserResponse, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return dto.UserResponse{}, err
    }
    
    return dto.UserResponse{
        ID:    int(user.ID),
        Name:  user.Name,
        Email: user.Email,
    }, nil
}

func (s *UserService) Create(ctx context.Context, name, email string) (dto.UserResponse, error) {
    user := &entity.User{Name: name, Email: email}
    
    if err := s.repo.Save(ctx, user); err != nil {
        return dto.UserResponse{}, err
    }
    
    return dto.UserResponse{
        ID:    int(user.ID),
        Name:  user.Name,
        Email: user.Email,
    }, nil
}

repository/

Handles database access. SQL queries or ORM calls are located here.

go
// repository/user_repository.go
package repository

type UserRepository struct {
    db bun.IDB  // Accepts both bun.DB or bun.Tx
}

func NewUserRepository(db bun.IDB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*entity.User, error) {
    user := new(entity.User)
    err := r.db.NewSelect().
        Model(user).
        Where("id = ?", id).
        Scan(ctx)
    return user, err
}

func (r *UserRepository) Save(ctx context.Context, user *entity.User) error {
    _, err := r.db.NewInsert().
        Model(user).
        Exec(ctx)
    return err
}

entity/

Structures mapped to database tables.

go
// entity/user.go
package entity

type User struct {
    ID        int64     `bun:",pk,autoincrement"`
    Name      string    `bun:",notnull"`
    Email     string    `bun:",unique,notnull"`
    CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
    UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
}

dto/

Request/Response objects. Defines API contracts.

go
// dto/user_request.go
package dto

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

type UpdateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}
go
// dto/user_response.go
package dto

type UserResponse struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

routes/

Manages routes in one place. You can see at a glance which path is connected to which handler.

go
// routes/user_routes.go
package routes

func RegisterUserRoutes(app spine.App) {
    app.Route("GET", "/users", (*controller.UserController).GetUser)
    app.Route("POST", "/users", (*controller.UserController).CreateUser)
    app.Route("PUT", "/users", (*controller.UserController).UpdateUser)
    app.Route("DELETE", "/users", (*controller.UserController).DeleteUser)
}

interceptor/

Logic for pre/post-processing of requests. Handles transactions, logging, authentication, etc.

go
// interceptor/logging_interceptor.go
package interceptor

type LoggingInterceptor struct{}

func (i *LoggingInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
    log.Printf("[REQ] %s %s", ctx.Method(), ctx.Path())
    return nil
}

func (i *LoggingInterceptor) PostHandle(ctx core.ExecutionContext, meta core.HandlerMeta) {
    log.Printf("[RES] %s %s OK", ctx.Method(), ctx.Path())
}

func (i *LoggingInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
    if err != nil {
        log.Printf("[ERR] %s %s : %v", ctx.Method(), ctx.Path(), err)
    }
}

Dependency Flow

Core Principles

PrincipleDescription
Unidirectional DependencyController → Service → Repository (Reverse prohibited)
Separation of ConcernsEach layer performs only its role
Constructor InjectionAll dependencies are injected via constructors
Interface UsageRepository accepts bun.IDB to support both DB/Tx

Next Steps