Skip to content

axen-software/go-starter

Repository files navigation

go-starter

A production-ready Go REST API template built with Echo v4, entgo ORM, Casbin authorization, and the internal forge/genesis framework. Designed to be cloned as the foundation for every new Go project.


Table of Contents


Overview

go-starter is an opinionated Go backend template. Its goal is to eliminate the setup cost of every new project by providing:

  • A working multi-module REST API with authentication and authorization baked in
  • A code-generated ORM layer (ent) with automatic audit trails, soft deletes, and versioning
  • A declarative permission system driven by Go structs — no manual SQL policy inserts
  • A type-safe seeder system with idempotent execution and sequence management
  • A consistent email pipeline using SMTP (Poste.io / Mailtrap / any provider)
  • Atlas-managed migrations with full diff, apply, rollback, and lint support

Tech Stack

Concern Library / Tool
HTTP Framework Echo v4
ORM entgo (code generation)
Authorization Casbin v2 + Blank-Xu/sql-adapter
JWT golang-jwt/jwt v5
Password Hashing golang.org/x/crypto/bcrypt
Database PostgreSQL via pgx/v5
Migrations Atlas CLI
Config Viper-based genesis config (YAML + env override)
Email SMTP via custom poste package
Encryption AES-256 (invitation code encryption)
Framework Internal forge + genesis packages
Build Go 1.25+

Project Structure

go-starter/
├── cmd/
│   ├── rest/
│   │   └── main.go              # REST server entry point
│   ├── service/
│   │   └── service.go           # Module registry
│   └── db-cli/
│       └── main.go              # Database CLI (seeder, truncate)
│
├── database/                    # Seeder system
│   ├── seed.go                  # Runner factory
│   ├── constants/
│   │   └── constants.go         # Seed user IDs & default credentials
│   ├── data/
│   │   └── users.go             # Static seed data
│   ├── generators/
│   │   └── password.go          # bcrypt helper for seeders
│   └── seeders/
│       ├── seeder.go            # Seeder interface
│       ├── runner.go            # Runner: Register/RunAll/Run
│       ├── users/
│       │   └── seeder.go        # Users seeder (idempotent + seq reset)
│       └── permissions/
│           └── seeder.go        # Permissions seeder
│
├── ent/                         # Code-generated ORM (DO NOT edit *.go manually)
│   ├── schema/                  # Source of truth – edit these
│   │   ├── user.go
│   │   ├── auditlog.go
│   │   ├── casbinpolicy.go
│   │   ├── permissionmatrix.go
│   │   ├── permissionpolicy.go
│   │   ├── rolepermission.go
│   │   └── userpasswordrecoverytoken.go
│   ├── mixin/
│   │   └── common.go            # TimestampMixin, SoftDeleteMixin, AuditMixin
│   ├── hook/
│   │   └── common/              # OnCreate, OnUpdate, OnSoftDelete, OnValidate hooks
│   ├── interceptors/
│   │   └── common/              # SoftDeleteFilter interceptor
│   └── migrate/
│       └── migrations/          # Atlas SQL migration files
│
├── internal/
│   ├── auth/                    # Auth module
│   │   ├── config/              # Module wiring
│   │   ├── delivery/rest/       # HTTP handlers + routes
│   │   ├── domain/              # DTOs, interfaces, usecases, services
│   │   ├── errors/              # Domain error types
│   │   ├── infrastructures/     # Repositories + schemas
│   │   └── rpc/                 # gRPC (placeholder)
│   │
│   ├── users/                   # Users module
│   │   ├── config/              # Module wiring + REST configurator
│   │   ├── delivery/rest/       # Handlers, routes, dependencies
│   │   ├── domain/              # DTOs, models, interfaces, usecases, services
│   │   ├── errors/              # Domain error types
│   │   ├── infrastructures/     # Repositories + email mapper
│   │   └── packages/            # AES encryption, etc.
│   │
│   ├── emails/                  # Email domain (templates + send usecases)
│   │   ├── domain/
│   │   └── infrastructures/
│   │       ├── emailtemplate/   # Embedded HTML templates
│   │       └── mapper/          # Poste SMTP adapter
│   │
│   ├── middleware/
│   │   ├── authentication.go    # JWT middleware
│   │   ├── casbin.go            # Casbin authorization middleware
│   │   └── request_id.go        # Request ID injection
│   │
│   ├── shared/
│   │   ├── config/              # Shared config struct (PosteEmailConfig, etc.)
│   │   ├── domain/              # Shared enums, models, services (HashPassword etc.)
│   │   ├── enums/               # App-level enums (CompanyName, EmailFrom, etc.)
│   │   ├── interfaces/          # ConfigReader, AuthUser interfaces
│   │   ├── module/              # Module interface + Dependencies factory
│   │   └── packages/            # JWT, auth helpers
│   │
│   └── hooks/                   # Context helpers for ent hooks
│       └── context/
│
├── poste/                       # SMTP email client
│   ├── configs/                 # EmailConfig struct + LoadEmailConfig()
│   ├── domain/                  # Interfaces + DTOs
│   └── infrastructures/
│       └── poste.go             # SMTP send implementation (TLS + SSL)
│
├── atlas.hcl                    # Atlas migration config
├── model.conf                   # Casbin model
├── config.local.yml             # Local environment config
├── Dockerfile                   # Multi-stage Docker build
├── Makefile                     # Migration + codegen helpers (Linux/Mac)
├── migrate.ps1                  # Migration helpers (Windows)
└── .env                         # Local secret overrides (not committed)

Architecture

Module System

All business domains are implemented as modules. A module is any struct that satisfies:

type Module interface {
    Name() string
    Configure(ctx context.Context, deps *Dependencies) error
}

Modules are registered in cmd/service/service.go:

module.NewRegistry(deps.Logger).
    Register(
        auth.New(),
        users.New(),
        // Add your new module here
    ).
    ConfigureAll(ctx, deps)

Each module's Configure() receives a shared *Dependencies instance containing:

Field Type Description
Logger logger.Logger Structured logger
RESTServer rest.Server Echo server wrapper
GRPCServer grpc.Server gRPC server
EntClient *ent.Client Database ORM client
MainDB *sql.DB Raw SQL connection
ServiceConfig _config.Config Parsed service config
InfraConfig *config.Config Infrastructure config
Enforcer *casbin.Enforcer Casbin enforcer
PosteClient poste.EmailClient SMTP client
Storage storage.Storage GCS storage
CloudTasks ctasks.Ctasks Google Cloud Tasks

Layered Architecture per Module

Each module follows a strict layer separation:

delivery/rest/     ← HTTP: bind, validate, call usecase, return response
domain/usecases/   ← Orchestration: combines services/repos, no HTTP knowledge
domain/services/   ← Business logic: pure Go, no infrastructure
domain/interfaces/ ← Contracts between layers (interfaces only)
domain/dtos/       ← Input/output shapes
domain/models/     ← Domain models (no ent dependency)
infrastructures/   ← Implementations: repositories (ent), mappers (email)

Dependencies always point inward — infrastructure depends on domain, never the reverse.

Request Lifecycle

HTTP Request
    │
    ▼
Echo Router
    │
    ├─ RequestID middleware    (inject X-Request-ID)
    ├─ Gzip middleware
    ├─ CORS middleware
    ├─ Logger middleware
    ├─ BodyLimit middleware
    │
    ├─ Authentication middleware  (validate JWT → inject claims to context)
    ├─ Authorization middleware   (Casbin enforce: role + path + method)
    │
    ▼
Handler (delivery/rest)
    │  bind + validate DTO
    ▼
Usecase (domain/usecases)
    │  orchestrate business logic
    ▼
Service / Repository (domain/services + infrastructures)
    │
    ▼
Ent Client → PostgreSQL

Database

Ent Schema

All schemas live in ent/schema/. Never edit the generated files (anything outside ent/schema/). After changing a schema, run:

go generate ./ent/...

Entities

Entity Table Description
User users App users with role, status, invitation flow
AuditLog audit_logs Immutable audit trail for all entity changes
CasbinPolicy casbin_policies Casbin RBAC policy rows
PermissionMatrix permission_matrixes Permission type definitions per module
PermissionPolicy permission_policies Links permission → user
RolePermission role_permissions Links role → permission
UserPasswordRecoveryToken user_password_recovery_tokens Password reset tokens

User Fields

Field Type Constraints
id int64 Unique, set explicitly
email string Unique, not empty
name string Not empty
password string Sensitive (excluded from queries by default)
role enum user, admin
status enum invited, active, inactive
invitation_code string Unique, optional (AES-encrypted email)
invitation_expired_at time.Time Optional, 72h from invite

Mixins

All relevant entities automatically inherit shared fields via mixins:

TimestampMixin

created_at  time.Time  (immutable, auto-set on create)
updated_at  time.Time  (auto-updated on every mutation)

SoftDeleteMixin

deleted_at  *time.Time  (set on "delete" — physical delete never happens)
deleted_by  *uuid.UUID  (user who deleted it)

The SoftDeleteFilter interceptor automatically appends WHERE deleted_at IS NULL to all queries so soft-deleted records are invisible.

AuditMixin

created_by  int64   (user ID who created the record)
updated_by  int64   (user ID who last updated the record)
created_ip  string  (IP address at creation)
updated_ip  string  (IP address at last update)

Hooks

Global ent hooks run on every mutation for entities that declare them in their Hooks() method.

Hook Trigger Effect
common.OnCreate() OpCreate Auto-fills created_by, created_ip from context
common.OnUpdate() OpUpdate Auto-fills updated_by, updated_ip from context
common.OnSoftDelete() OpDelete / OpDeleteOne Converts DELETE to UPDATE, sets deleted_at and deleted_by
common.OnValidateBusinessRules() All ops Runs domain-level validation rules before commit

The hooks read actor context injected by the Authentication middleware automatically. For non-HTTP paths (background jobs, seeders) inject manually:

import hookctx "github.com/axen-software/go-starter/internal/hooks/context"

// int64 user ID — matches the Int64 fields in AuditMixin
ctx = hookctx.WithActorInt64(ctx, userID, "user")  // actor_type: "user" | "admin" | "system"
ctx = hookctx.WithIPAddress(ctx, "127.0.0.1")

Interceptors

Interceptor Registered via Effect
SoftDeleteFilter() mixin.Interceptors() Appends WHERE deleted_at IS NULL to all SELECT queries automatically
QueryAuditLogger() client.Intercept() Writes an audit_logs row after every successful read query

Audit Log

Every database operation is automatically recorded in the audit_logs table. No changes are required in repositories or handlers — it works transparently via two global hooks registered at entClient creation in module.go.

How it works

HTTP Request
  → Authentication middleware   ← injects actor_id, ip, user-agent into ctx
  → Handler → UseCase → Repository → ent.Client
                                          |
                          ┌───────────────┴───────────────┐
                          ▼                               ▼
                 MutationLogger                  QueryAuditLogger
              (CREATE/UPDATE/DELETE)                (SELECT)
                          └───────────────┬───────────────┘
                                          ▼
                                  audit_logs table
                                    (async goroutine)

What gets logged

Operation action Hook type
Create() create MutationLogger via client.Use()
UpdateOne() / Update() update MutationLogger via client.Use()
DeleteOne() / Delete() delete MutationLogger via client.Use()
Query().All/Only/First() read QueryAuditLogger via client.Intercept()

Fields populated automatically

Field Source
actor_id JWT claims.ID — injected by Authentication middleware
actor_type "user" (default) or "system" when no token
ip_address c.RealIP() — injected by Authentication middleware
user_agent User-Agent header — injected by Authentication middleware
entity_type ent entity name (e.g. "User", "AuditLog")
entity_id Primary key of the affected row
action create / update / delete / read
changes Fields written (mutation) or result_count + duration_ms (read)

Marking sensitive / PII queries

Wrap the context before any read query that accesses PII to add changes["sensitive"] = true to the audit row:

import interceptorcommon "github.com/axen-software/go-starter/ent/interceptors/common"

func (r *UserReadRepository) FindByEmail(ctx context.Context, email string) (*ent.User, error) {
    ctx = interceptorcommon.MarkSensitiveQuery(ctx)  // tags this read as PII
    return r.client.User.Query().
        Where(user.Email(email)).
        Only(ctx)
}

Skipping audit logging

For endpoints, background jobs, or seeders that should not produce audit records:

import hookctx "github.com/axen-software/go-starter/internal/hooks/context"

// Skip ONLY audit log (OnCreate/OnUpdate hooks still run — created_by etc. still filled)
ctx = hookctx.WithSkipAudit(ctx)   // skips QueryAuditLogger reads

// Skip ALL hooks including audit + created_by/updated_by population
ctx = hookctx.WithSkipHooks(ctx)   // use in seeders / migrations / schedulers
Scenario Recommended
Public / health-check endpoint WithSkipAudit(ctx)
Internal service-to-service call WithSkipAudit(ctx)
Seeder / migration script WithSkipHooks(ctx)
Background scheduler WithSkipHooks(ctx)

Note: The AuditLog entity itself is always excluded from logging to prevent infinite recursion.


Migrations with Atlas

Atlas manages all schema migrations by diffing the current Ent schema against the last migration state.

Creating a migration:

# Linux/Mac
make migrate-create NAME=add_phone_to_users

# Windows
.\migrate.ps1 create add_phone_to_users

Applying migrations:

# Linux/Mac
make migrate-apply

# Windows
.\migrate.ps1 apply

Migration files are stored in ent/migrate/migrations/ and are versioned. The atlas.hcl file defines the local, staging, and production environments.


Authentication & Authorization

JWT Authentication

JWT tokens are issued on login (POST /v1/auth/login) and must be included in all protected requests:

Authorization: Bearer <token>

The Authentication middleware:

  1. Reads the Authorization header
  2. Strips Bearer prefix
  3. Parses and validates the JWT signature using the secret from JWT_SECRET env var
  4. Injects claims into request context as models.AuthUser

Claims structure:

type AuthUser struct {
    ID     int64
    Role   string
    Status string
}

The JWT package is accessed via authentications.JWT() which reads JWT_SECRET from environment.

Casbin Authorization

Every protected route is guarded by the Authorization middleware after authentication.

Casbin model (model.conf):

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act

This means: a request is allowed only if there's an exact match for (role, path, method) in the casbin_policies table.

Path normalization: The middleware strips version prefixes and normalizes path params before enforcing:

/v1/users/invite    →  users/invite
/v1/users/:id       →  users/:param
/api/v2/dashboard   →  dashboard

Policy table: casbin_policies (configured via casbin.policyTable in config YAML).

Permission Schema System

Permissions are defined declaratively in Go code at:

internal/auth/infrastructures/schemas/permission_schemas.go

Each entry defines:

  • Module — logical grouping (auth, dashboard, user_settings, users_info)
  • Permission type — unique identifier (e.g. manage_users_and_roles)
  • Label & Description
  • Actions — list of {path, method} pairs
  • Roles — which roles get full access
  • ViewOnlyRoles — which roles get read-only access (optional)

Example:

{
    Module: "user_settings",
    Permissions: []Permission{
        {
            Type:        "manage_users_and_roles",
            Label:       "Manage Users and Roles",
            Description: "Invite, edit, deactivate, and manage users",
            Actions: []Action{
                {Path: "users/invite", Method: "POST"},
                {Path: "users/list",   Method: "GET"},
                // ...
            },
            Roles: []string{"admin"},
        },
    },
}

To apply permissions to the database, call:

POST /v1/auth/setup-permissions

This endpoint:

  1. Upserts all PermissionMatrix records
  2. Upserts all RolePermission records
  3. Upserts all PermissionPolicy records
  4. Generates and syncs Casbin policy rows in casbin_policies

Run this after every deployment that changes permission_schemas.go.


Modules

Auth Module

Package: internal/auth

Routes:

Method Path Auth Description
POST /v1/auth/login Login with email + password, returns JWT
POST /v1/auth/setup-permissions Sync permission schemas to DB + Casbin
GET /v1/auth/role-permissions ✅ JWT only Get role permission list

Login flow:

  1. Validate email (non-empty) and password (non-empty)
  2. Find user by email
  3. Verify user status is active
  4. Compare bcrypt hash
  5. Generate and return JWT

Password rules:

  • Login: only non-empty check — no regex
  • Registration: full regex validation (uppercase, lowercase, number, special char, min 8)

Users Module

Package: internal/users

Public routes (no auth):

Method Path Description
POST /v1/users/account Create account from invitation (set password)
GET /v1/users/invite Get invitation info by token
POST /v1/users/password-recovery Request password reset email
GET /v1/users/password-recovery-token/:token Validate reset token
POST /v1/users/reset-password Set new password using reset token

Protected routes (JWT + Casbin):

Method Path Permission Description
GET /v1/users/profile Get own profile
GET /v1/users/list manage_users_and_roles List all users
POST /v1/users/invite manage_users_and_roles Invite new user
PUT /v1/users/invite/resend manage_users_and_roles Resend invitation email
PUT /v1/users/:id manage_users_and_roles Edit user
PATCH /v1/users/:id/deactivate manage_users_and_roles Deactivate user
PATCH /v1/users/:id/reactivate manage_users_and_roles Reactivate user
DELETE /v1/users/:id/cancel manage_users_and_roles Cancel/remove user

Invite flow:

  1. Check email uniqueness
  2. Generate 16-char random password
  3. Hash password (bcrypt)
  4. AES-encrypt email → invitation_code
  5. Save user with status invited
  6. Async: send invitation email with link containing ?token=<invitation_code>
  7. Frontend decodes token → GET /v1/users/invite?token=... → show name/role
  8. User sets password → POST /v1/users/account

Invitation expiry: 72 hours from invite time.


Email System (Poste)

The email system is built around three layers:

EmailMapper (users layer)
    ↓ calls
SendUserInvitationEmailHandler (emails/domain/usecases)
    ↓ uses
TemplateReader (embedded HTML templates)
    ↓ renders into
SendGridMapper / PosteMapper (emails/infrastructures/mapper)
    ↓ sends via
EmailService (poste/infrastructures) → SMTP server

Email Templates

HTML templates are embedded at compile time using //go:embed:

Template File Used For
forgot_password.html Password recovery email
user_invitation.html User invitation email

Template variables use Go's html/template syntax:

<p>Hello {{.UserName}}</p>
<p>{{.AdminName}} has invited you to join {{.CompanyName}}</p>
<a href="{{.InvitationLink}}">Create Your Account</a>

SMTP Configuration

Configure in config.local.yml under service.posteEmail:

service:
  posteEmail:
    smtpHost: "your-smtp-host"
    smtpPort: "587" # 587 = STARTTLS, 465 = SSL
    smtpUsername: "user@domain.com"
    smtpPassword: "password"
    useTLS: true # true = STARTTLS (port 587), false = SSL (port 465)
    fromName: "Your App"
    fromEmail: "noreply@domain.com"

For local development, use Mailtrap (free):

service:
  posteEmail:
    smtpHost: "sandbox.smtp.mailtrap.io"
    smtpPort: "587"
    smtpUsername: "<mailtrap-username>"
    smtpPassword: "<mailtrap-password>"
    useTLS: true
    fromName: "Dev"
    fromEmail: "dev@localhost"

Note: Email sending runs in a goroutine — failures are logged but do not affect the HTTP response status.


Seeder System

The seeder system lives in database/ and is run via the db-cli tool.

Running Seeders

# Seed everything
go run ./cmd/db-cli -seed=all

# Seed only users
go run ./cmd/db-cli -seed=users

# Seed only permissions
go run ./cmd/db-cli -seed=permissions

# Multiple specific seeders (comma-separated)
go run ./cmd/db-cli -seed=users,permissions

# Fresh start: truncate all tables then seed
go run ./cmd/db-cli -fresh -seed=all

The CLI reads the same config.{APP_ENV}.yml as the REST service — no separate DATABASE_URL needed.

Default seed users:

Email Password Role ID
admin@example.com Admin@1234 admin 1
user@example.com User@1234 user 2

Change default passwords after first deploy.

Idempotent behavior: Seeders check for existing records before inserting. Running the same seeder twice is safe.

Sequence reset: The users seeder automatically resets the PostgreSQL auto-increment sequence after explicit-ID inserts, preventing primary key conflicts when creating new users via the API.

Adding a New Seeder

  1. Create database/seeders/<name>/seeder.go implementing the Seeder interface:
package myseeder

import (
    "context"
    "github.com/axen-software/go-starter/ent"
)

const Name = "myentity"

type MySeeder struct { client *ent.Client }

func New(client *ent.Client) *MySeeder { return &MySeeder{client: client} }

func (s *MySeeder) Name() string { return Name }

func (s *MySeeder) Run(ctx context.Context) error {
    // idempotent insert logic here
    return nil
}
  1. Register it in database/seed.go:
func New(client *ent.Client, db *sql.DB) *seeders.Runner {
    r := seeders.NewRunner()
    r.Register(userseeder.New(client, db))
    r.Register(permissionseeder.New(client))
    r.Register(myseeder.New(client))  // ← add here, after dependencies
    return r
}

Configuration

Config File Structure

The active config file is determined by APP_ENV (default: local):

  • APP_ENV=local → reads config.local.yml
  • APP_ENV=staging → reads config.staging.yml
  • APP_ENV=production → reads config.production.yml

Full config.local.yml structure:

app:
  env: local
  serviceName: go-starter
  deliveryType: rest

server:
  port: 8123

log:
  level: debug

dbs:
  primary:
    driver: pgx
    host: localhost
    port: 5432
    name: your_db_name
    username: your_db_user
    password: your_db_password
    sslMode: disable

email:
  enable: true
  apiKey: "" # Legacy SendGrid key (unused if posteEmail is set)
  defaultSenderName: ""
  defaultSenderEmail: ""

casbin:
  enable: true
  modelPath: ./model.conf
  policyTable: casbin_policies
  dbConfigName: primary # Which DB from `dbs` to use

service:
  frontend:
    baseUrl: "https://yourapp.com"
    resetPasswordPath: "/recover-password"
    userInvitationPath: "/register"
  app:
    serviceAccountEmail: ""
    mainDatabase: "primary"
  posteEmail:
    smtpHost: "sandbox.smtp.mailtrap.io"
    smtpPort: "587"
    smtpUsername: ""
    smtpPassword: ""
    useTLS: true
    fromName: "Your App"
    fromEmail: "noreply@yourapp.com"

Environment Variables

These are read directly via os.Getenv() and must be set before startup (.env is loaded automatically at startup):

Variable Required Description
AES_KEY 64-char hex string (32 bytes) — used for AES-256 invitation code encryption
JWT_SECRET Base64 or arbitrary string for JWT HMAC signing
APP_ENV Deployment environment. Default: local

Generating secure values:

# AES_KEY (32 random bytes as hex)
openssl rand -hex 32 | tr '[:lower:]' '[:upper:]'

# JWT_SECRET (32 random bytes as base64)
openssl rand -base64 32

Config Priority

config.{APP_ENV}.yml   (base values)
    ↑ overridden by
os.Getenv / .env       (for fields with `env` tag in service config structs)

Important: The genesis framework only overrides fields that have an env tag on the struct. Most service.* config (like SMTP credentials) does NOT have env tags and must be set in the YAML file directly.


API Endpoints

Summary

POST   /v1/auth/login                          Public — Returns JWT
POST   /v1/auth/setup-permissions              Public — Sync permissions to DB
GET    /v1/auth/role-permissions               🔐 JWT — Get role-based permissions

POST   /v1/users/account                       Public — Create account (from invite)
GET    /v1/users/invite                        Public — Get invite info by token
POST   /v1/users/password-recovery            Public — Request password reset
GET    /v1/users/password-recovery-token/:t   Public — Validate reset token
POST   /v1/users/reset-password               Public — Reset password

GET    /v1/users/profile                       🔐 JWT — Get own profile
GET    /v1/users/list                          🔐 JWT+Casbin — List all users
POST   /v1/users/invite                        🔐 JWT+Casbin — Invite new user
PUT    /v1/users/invite/resend                 🔐 JWT+Casbin — Resend invite email
PUT    /v1/users/:id                           🔐 JWT+Casbin — Edit user
PATCH  /v1/users/:id/deactivate               🔐 JWT+Casbin — Deactivate user
PATCH  /v1/users/:id/reactivate               🔐 JWT+Casbin — Reactivate user
DELETE /v1/users/:id/cancel                   🔐 JWT+Casbin — Cancel user

Response Format

Success:

{
  "status": 200,
  "data": { ... }
}

Error:

{
  "title": "Bad Request",
  "status": 400,
  "message": "email already exists",
  "error_detail": "...",
  "stackTrace": "..."
}

Getting Started

Prerequisites

  • Go 1.21+
  • PostgreSQL 15+
  • Atlas CLI
  • Git (with SSH access to private github.com/axen-software repos)

Local Setup

1. Clone and configure Go private modules:

git clone git@github.com:axen-software/go-starter.git
cd go-starter

git config --global url."ssh://git@github.com/".insteadOf "https://github.com/"
export GOPRIVATE=github.com/axen-software
go mod download

2. Create your database:

CREATE DATABASE go_starter_local;

3. Configure the app:

Copy and edit the config:

cp config.local.yml config.local.yml  # already exists, just edit it

Update dbs.primary with your Postgres credentials.

4. Create .env:

AES_KEY=<output of: openssl rand -hex 32 | tr '[:lower:]' '[:upper:]'>
JWT_SECRET=<output of: openssl rand -base64 32>

5. Run migrations:

# Windows
.\migrate.ps1 apply

# Linux/Mac
make migrate-apply

6. Seed the database:

go run ./cmd/db-cli -seed=all

7. Start the server:

go run ./cmd/rest/main.go

8. Setup permissions:

curl -X POST http://localhost:8123/v1/auth/setup-permissions

9. Login:

curl -X POST http://localhost:8123/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"Admin@1234"}'

Development Workflow

Adding a New Module

  1. Create the module directory structure:
internal/
└── mymodule/
    ├── config/
    │   ├── module.go           # Module entry point
    │   └── delivery/
    │       └── rest.configurator.go
    ├── delivery/rest/
    │   ├── handlers.go
    │   ├── routes.go
    │   └── dependencies.go
    ├── domain/
    │   ├── dtos/
    │   ├── interfaces/
    │   ├── models/
    │   └── usecases/
    ├── errors/
    └── infrastructures/
        └── repositories/
  1. Implement Module interface in config/module.go:
type myModule struct{}

func New() module.Module { return &myModule{} }
func (m *myModule) Name() string { return "mymodule" }
func (m *myModule) Configure(ctx context.Context, deps *module.Dependencies) error {
    // wire up repositories, handlers, routes
    return nil
}
  1. Register in cmd/service/service.go:
module.NewRegistry(deps.Logger).
    Register(auth.New(), users.New(), mymodule.New()).
    ConfigureAll(ctx, deps)

Adding a New Ent Schema

  1. Create ent/schema/myentity.go:
type MyEntity struct { ent.Schema }

func (MyEntity) Mixin() []ent.Mixin {
    return []ent.Mixin{
        entmixin.TimestampMixin{},
        entmixin.SoftDeleteMixin{},
        entmixin.AuditMixin{},
    }
}

func (MyEntity) Fields() []ent.Field { return []ent.Field{ ... } }
func (MyEntity) Edges() []ent.Edge { return nil }
func (MyEntity) Hooks() []ent.Hook {
    return []ent.Hook{
        common.OnCreate(),
        common.OnUpdate(),
        common.OnSoftDelete(),
    }
}
  1. Regenerate code:
go generate ./ent/...
  1. Create and apply migration:
make migrate-create NAME=add_my_entity   # or .\migrate.ps1 create add_my_entity
make migrate-apply                        # or .\migrate.ps1 apply

Adding a New Migration

Always create migrations via Atlas, never manually:

# After changing ent/schema/*.go and running go generate:
make migrate-create NAME=describe_the_change

# Review the generated SQL in ent/migrate/migrations/
# Then apply:
make migrate-apply

Adding a New Permission

  1. Edit internal/auth/infrastructures/schemas/permission_schemas.go — add your permission block:
{
    Module: "your_module",
    Permissions: []Permission{
        {
            Type:        "do_something",
            Label:       "Do Something",
            Description: "Allows doing something",
            Actions: []Action{
                {Path: "your-resource", Method: "POST"},
                {Path: "your-resource/:param", Method: "GET"},
            },
            Roles: []string{"admin"},
        },
    },
},
  1. Add the module to the module enum in ent/schema/permissionmatrix.go if it's new.

  2. Add the module constant to internal/auth/domain/enums/ if needed.

  3. Re-run setup-permissions after deploying:

curl -X POST http://localhost:8123/v1/auth/setup-permissions

Docker

The Dockerfile uses a multi-stage build:

  1. Builder stage (golang:1.23-alpine) — fetches private modules via SSH, compiles binary
  2. Runner stage (scratch) — ultra-minimal image with only the binary + certs + config

Building:

docker build \
  --ssh default=$SSH_AUTH_SOCK \
  --build-arg APP_ENV=production \
  -t go-starter:latest .

Running:

docker run -p 8080:8080 \
  -e AES_KEY=<key> \
  -e JWT_SECRET=<secret> \
  -e APP_ENV=production \
  go-starter:latest

The production config (config.production.yml) and model.conf are copied into the image at build time.


Makefile Reference

Command Description
make help Show all available commands
make migrate-create NAME=xxx Generate migration from schema diff
make migrate-apply Apply pending migrations (local)
make migrate-apply-staging Apply migrations to staging
make migrate-apply-prod Apply migrations to production (with confirmation)
make migrate-status Show current migration state
make migrate-pending Show pending migrations
make ent-generate Regenerate ent code from schemas
make ent-new NAME=xxx Scaffold a new ent schema
make ent-describe Print schema description

Windows users: Use migrate.ps1 instead of make for migration commands:

.\migrate.ps1 create <name>
.\migrate.ps1 apply
.\migrate.ps1 status

Notes for New Projects

When cloning this template for a new project:

  1. Rename the module in go.mod from github.com/axen-software/go-starter to your module path
  2. Update enums in internal/shared/enums/app.go — change CompanyName, EmailFrom, EmailName
  3. Update default credentials in database/constants/constants.go
  4. Clear permission_schemas.go and define your own permission structure
  5. Add your business domain modules following the module pattern
  6. Create environment-specific config files (config.staging.yml, config.production.yml)
  7. Never commit .env, config.local.yml — add them to .gitignore

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors