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.
- Overview
- Tech Stack
- Project Structure
- Architecture
- Database
- Authentication & Authorization
- Modules
- Email System (Poste)
- Seeder System
- Configuration
- API Endpoints
- Getting Started
- Development Workflow
- Docker
- Makefile Reference
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
| 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) |
SMTP via custom poste package |
|
| Encryption | AES-256 (invitation code encryption) |
| Framework | Internal forge + genesis packages |
| Build | Go 1.25+ |
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)
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 |
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.
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
All schemas live in ent/schema/. Never edit the generated files (anything outside ent/schema/). After changing a schema, run:
go generate ./ent/...| 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 |
| 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 |
All relevant entities automatically inherit shared fields via mixins:
created_at time.Time (immutable, auto-set on create)
updated_at time.Time (auto-updated on every mutation)
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.
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)
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")| 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 |
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.
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)
| 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() |
| 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) |
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)
}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
AuditLogentity itself is always excluded from logging to prevent infinite recursion.
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_usersApplying migrations:
# Linux/Mac
make migrate-apply
# Windows
.\migrate.ps1 applyMigration files are stored in ent/migrate/migrations/ and are versioned. The atlas.hcl file defines the local, staging, and production environments.
JWT tokens are issued on login (POST /v1/auth/login) and must be included in all protected requests:
Authorization: Bearer <token>
The Authentication middleware:
- Reads the
Authorizationheader - Strips
Bearerprefix - Parses and validates the JWT signature using the secret from
JWT_SECRETenv var - 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.
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.actThis 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).
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:
- Upserts all
PermissionMatrixrecords - Upserts all
RolePermissionrecords - Upserts all
PermissionPolicyrecords - Generates and syncs Casbin policy rows in
casbin_policies
Run this after every deployment that changes
permission_schemas.go.
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:
- Validate
email(non-empty) andpassword(non-empty) - Find user by email
- Verify user status is
active - Compare bcrypt hash
- Generate and return JWT
Password rules:
- Login: only non-empty check — no regex
- Registration: full regex validation (uppercase, lowercase, number, special char, min 8)
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:
- Check email uniqueness
- Generate 16-char random password
- Hash password (bcrypt)
- AES-encrypt email →
invitation_code - Save user with status
invited - Async: send invitation email with link containing
?token=<invitation_code> - Frontend decodes token →
GET /v1/users/invite?token=...→ show name/role - User sets password →
POST /v1/users/account
Invitation expiry: 72 hours from invite time.
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
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>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.
The seeder system lives in database/ and is run via the db-cli tool.
# 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=allThe CLI reads the same config.{APP_ENV}.yml as the REST service — no separate DATABASE_URL needed.
Default seed users:
| 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.
- Create
database/seeders/<name>/seeder.goimplementing theSeederinterface:
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
}- 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
}The active config file is determined by APP_ENV (default: local):
APP_ENV=local→ readsconfig.local.ymlAPP_ENV=staging→ readsconfig.staging.ymlAPP_ENV=production→ readsconfig.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"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 32config.{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
envtag on the struct. Mostservice.*config (like SMTP credentials) does NOT have env tags and must be set in the YAML file directly.
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
Success:
{
"status": 200,
"data": { ... }
}Error:
{
"title": "Bad Request",
"status": 400,
"message": "email already exists",
"error_detail": "...",
"stackTrace": "..."
}- Go 1.21+
- PostgreSQL 15+
- Atlas CLI
- Git (with SSH access to private
github.com/axen-softwarerepos)
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 download2. 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 itUpdate 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-apply6. Seed the database:
go run ./cmd/db-cli -seed=all7. Start the server:
go run ./cmd/rest/main.go8. Setup permissions:
curl -X POST http://localhost:8123/v1/auth/setup-permissions9. Login:
curl -X POST http://localhost:8123/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"Admin@1234"}'- 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/
- Implement
Moduleinterface inconfig/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
}- Register in
cmd/service/service.go:
module.NewRegistry(deps.Logger).
Register(auth.New(), users.New(), mymodule.New()).
ConfigureAll(ctx, deps)- 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(),
}
}- Regenerate code:
go generate ./ent/...- Create and apply migration:
make migrate-create NAME=add_my_entity # or .\migrate.ps1 create add_my_entity
make migrate-apply # or .\migrate.ps1 applyAlways 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- 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"},
},
},
},-
Add the module to the
moduleenum inent/schema/permissionmatrix.goif it's new. -
Add the module constant to
internal/auth/domain/enums/if needed. -
Re-run setup-permissions after deploying:
curl -X POST http://localhost:8123/v1/auth/setup-permissionsThe Dockerfile uses a multi-stage build:
- Builder stage (
golang:1.23-alpine) — fetches private modules via SSH, compiles binary - 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:latestThe production config (
config.production.yml) andmodel.confare copied into the image at build time.
| 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 statusWhen cloning this template for a new project:
- Rename the module in
go.modfromgithub.com/axen-software/go-starterto your module path - Update enums in
internal/shared/enums/app.go— changeCompanyName,EmailFrom,EmailName - Update default credentials in
database/constants/constants.go - Clear
permission_schemas.goand define your own permission structure - Add your business domain modules following the module pattern
- Create environment-specific config files (
config.staging.yml,config.production.yml) - Never commit
.env,config.local.yml— add them to.gitignore