Donisgo (formerly go_framework) is a modular Go web application framework for backend services. The documentation here explains the architecture, request flow, important environment variables, and how to run development tools (server and console).
- Overview
- Quick Start
- What's included
- What's NOT included (implement via plugins)
- Main components
- Bootstrapping
- Request flow
- Plugin system
- Environment variables
- Auth
- JWT utilities available
- Testing JWT functions
- DB
- Console commands overview
- Migration commands (console)
- Plugin quickstart (CLI generator)
- Plugin registration (manual)
- This is a minimalist, plugin-based Go web framework for building modular backend services
- Main binaries:
cmd/server/main.go(HTTP server) andcmd/console/main.go(CLI/migrations) - Core philosophy: provide essential infrastructure (DB, routing, plugins) and let you implement features via plugins or custom code
- No built-in authentication or user management - implement via plugins (recommended) or custom middleware
- Application bootstrap and dependency wiring are in
internal/app/bootstrap.go - Extensible via a plugin system that supports middleware, routes, services, migrations, and console commands
- Copy example env and edit values:
cp .env.example .env
# edit .env as needed- Run migrations (applies core then plugin migrations):
go run ./cmd/console migrate up- Start the server:
go run ./cmd/server- (Optional) Generate a new plugin:
go run ./cmd/console plugin new --id my-pluginThe plugin generator creates an initial migration with a timestamp prefix, in the same style as Laravel: YYYY_MM_DD_HHMMSS_name.up.sql and YYYY_MM_DD_HHMMSS_name.down.sql.
See sections below for more details.
✅ Database connectivity (GORM - PostgreSQL, MySQL, MariaDB)
✅ Database migrations system
✅ Plugin architecture (middleware, routes, services, migrations, console commands)
✅ Plugin generator CLI (plugin new)
✅ JWT utilities (token generation/verification)
✅ CORS configuration
✅ Console commands (migrations, plugin generator, user management stub)
✅ KeyDB/Redis client (for flash messages)
✅ Mailer utilities
✅ Transaction helpers
✅ Swagger documentation support
❌ Authentication service & middleware
❌ User/Admin models
❌ Authorization/permissions system
❌ Built-in CRUD endpoints
❌ Session management
❌ Password hashing/verification utilities
Recommended: Create an auth plugin using the plugin generator to implement authentication features.
- Bootstrap & wiring:
internal/app/bootstrap.go - Business services:
internal/admin/services - Database (GORM):
internal/db/gorm.go - Auth utilities (JWT):
internal/auth(token generation/verification helpers) - Plugin system:
internal/pluginloaderandinternal/plugins - CLI / migrations:
internal/console - Mailer:
internal/mail - KeyDB/Redis client:
internal/keydb - Storage abstraction:
internal/storage - Events:
internal/events - UUID v7 generator:
internal/uuid
A lightweight internal event bus is provided by internal/events. Handlers are invoked asynchronously in separate goroutines by default. Use Subscribe to register a handler (it returns an unsubscribe function) and Publish to emit events.
Example:
// subscribe to an event
unsub := events.Subscribe("user.created", func(ctx context.Context, payload interface{}) {
// handle event (payload can be any value)
// run quick background work or forward to worker queues
})
defer unsub()
// publish an event (delivered asynchronously to subscribers)
events.Publish("user.created", map[string]interface{}{"id": "user123"})See internal/events/events_example_test.go for a runnable test demonstrating Subscribe/Publish.
For synchronous data-sync between plugins the repo provides a helper RequestReply in internal/events.
It creates a unique reply topic for each request, publishes the request with a ReplyTo field, and waits (with timeout) for a single reply.
Key properties:
- Uses a per-request reply topic (no shared global reply channel) to avoid cross-talk.
- Caller supplies a timeout and context for cancellation.
- Subscribers must read
ReplyTofrom the request and publish their response to that topic.
See internal/events/request_reply.go and internal/events/request_reply_test.go for a concrete example with concurrent requesters.
When requesters use different timeouts, handlers may finish after some callers already timed out. To avoid leaking or processing stale replies:
- Include a deadline or cancel field in the request payload (e.g.
Deadlineas a Unix nano timestamp) becausePublishinvokes handlers with a background context. - Subscribers should check the deadline before doing expensive work and before publishing a reply; if the deadline passed, skip replying.
- Always publish replies to the specific
ReplyTotopic provided in the request so replies don't reach other requesters.
Example (publisher):
deadline := time.Now().Add(2 * time.Second).UnixNano()
req := map[string]interface{}{ "id": "42", "Deadline": deadline }
resp, err := events.RequestReply(ctx, "user.query", req, 2*time.Second)Example (subscriber):
events.Subscribe("user.query", func(ctx context.Context, payload interface{}) {
m, _ := payload.(map[string]interface{})
// read deadline (type assertions may vary)
if d, ok := m["Deadline"].(int64); ok {
if time.Now().UnixNano() > d {
// too late, skip
return
}
}
replyTo, _ := m["ReplyTo"].(string)
// do work and publish to replyTo
events.Publish(replyTo, map[string]interface{}{"ok": true})
})This pattern keeps reply scopes isolated and makes late replies harmless (they're ignored by the requester). If you need true cancellation of work, consider changing the publish API to forward a cancellable context or use a direct service call.
Configuration is read from environment variables. Use ./.env.example as a starting point.
Below are recommended variables with example values and short notes.
App
APP_ENV=development|staging|production — runtime environment, affects logging and error modes.APP_HOST=0.0.0.0APP_PORT=8080APP_DEBUG=true|false — enable verbose debug logs only in non-production.
Database (GORM)
DB_TYPE=postgres|mysql|mariadbDB_HOST=localhostDB_PORT=5432DB_NAME=app_dbDB_USER=postgresDB_PASSWORD=secret — do NOT commit secrets; use secret manager in production.DB_SSLMODE=disable|require (Postgres only)DB_MAX_OPEN_CONNS=50DB_MAX_IDLE_CONNS=10DB_CONN_MAX_LIFETIME_SEC=300 — connection max lifetime in seconds
Auth / Security
AUTH_JWT_SECRET=very_long_random_string — canonical secret used to sign JWTs (HS256). Keep secret and rotate periodically.JWT_ACCESS_EXP_SECONDS=900 — access token TTL in seconds (15 minutes recommended)JWT_REFRESH_EXP_SECONDS=1209600 — refresh token TTL in seconds (14 days recommended)
Note: legacy env names such as JWT_SECRET, JWT_ACCESS_SECRET, and JWT_REFRESH_SECRET are deprecated. The application will prefer AUTH_JWT_SECRET when present and fall back to legacy names for compatibility. Remove legacy vars from production .env to avoid confusion.
Mailer
SMTP_HOST=smtp.example.comSMTP_PORT=587SMTP_USER=SMTP_PASS=SMTP_FROM=admin@example.comSMTP_USE_TLS=true|false — enable TLS when supported by SMTP server.SMTP_STARTTLS=true|false — enable STARTTLS
Cache / Flash Messages (KeyDB/Redis)
KEYDB_HOST=127.0.0.1KEYDB_PORT=6379KEYDB_PASS= — optional passwordKEYDB_DB=0 — database number
Storage
STORAGE_DRIVER=local|s3STORAGE_ROOT=./storage — used whenSTORAGE_DRIVER=localSTORAGE_PUBLIC_URL=http://localhost:8080/assetsS3_BUCKET,S3_REGION,S3_ENDPOINT,S3_ACCESS_KEY,S3_SECRET_KEY— used whenSTORAGE_DRIVER=s3
When serving files (images, product assets, business assets) the application exposes public URLs that point to the storage location. Configure STORAGE_PUBLIC_URL so generated links (e.g. in APIs and emails) resolve correctly.
Guidelines:
- Local storage: when
STORAGE_DRIVER=localand the app serves/assets(see plugincatalogwhich registers static routes), setSTORAGE_PUBLIC_URLto your server base +/assets, e.g.http://localhost:8080/assetsorhttps://example.com/assets. - S3 (or other object storage): set
STORAGE_PUBLIC_URLto the public bucket endpoint or CDN fronting the bucket, e.g.https://my-bucket.s3.eu-west-1.amazonaws.comorhttps://cdn.example.com. - Trailing slash: avoid a trailing slash to keep URL joins predictable (the code appends paths like
/products/...).
Examples:
-
Local development (app serves
/assets):STORAGE_DRIVER=local STORAGE_ROOT=./storage STORAGE_PUBLIC_URL=http://localhost:8080/assets
See the example snippet in this document: Serving local storage from a plugin
-
S3 with CDN (recommended for production):
STORAGE_DRIVER=s3 S3_BUCKET=my-bucket S3_REGION=eu-west-1 S3_ENDPOINT= STORAGE_PUBLIC_URL=https://cdn.example.com
Deployment tips:
- If you front storage with Nginx or a CDN, point
STORAGE_PUBLIC_URLto the public host and configure the reverse proxy to serve the files from the application or the object store. - Make sure CORS and caching headers are configured correctly on the public host or CDN to allow your frontend origins to request assets.
- When using local storage in production, prefer a CDN or public bucket for scalability and to offload traffic from the app server.
CORS
CORS_ALLOWED_ORIGINS="http://localhost:5173,http://localhost:4321" — comma-separated list of allowed origins
Logging
LOG_LEVEL=debug|info|warn|error
Misc
APP_URL=http://localhost:3651ADMIN_URL=http://localhost:5173FRONT_URL=http://localhost:4321DOCKER_HOST_IP=127.0.0.1
Security notes
- Never commit
.envwith real secrets to version control; keep.env.examplegeneric. - For production, prefer secret stores (Vault, AWS Secrets Manager, Kubernetes Secrets) and inject at deploy time.
- Rotate keys/secrets and use minimal privilege for DB/service accounts.
This framework provides JWT utilities but does not include built-in authentication services or middleware. Authentication should be implemented via plugins or custom code.
Basic JWT functions (jwt.go):
// Generate tokens
token, err := auth.SignAccessToken(userID) // Generate access token
refresh, err := auth.SignRefreshToken(userID) // Generate refresh token
// Parse/verify tokens
userID, err := auth.ParseAccessToken(tokenStr) // Verify access token, returns user ID
userID, err := auth.ParseRefreshToken(tokenStr) // Verify refresh token, returns user ID
// Get token expiry settings
accessExp := auth.AccessExpirySeconds() // e.g., 900 (15 minutes)
refreshExp := auth.RefreshExpirySeconds() // e.g., 1209600 (14 days)Advanced JWT with custom claims (claims_tokens.go):
// Generate token with admin_id and level claims
token, expTime, err := auth.GenerateAccessTokenWithLevel(
adminID,
"admin", // level: "admin", "user", etc.
15 * time.Minute,
)
// Parse token and get claims
claims, err := auth.ParseAccessTokenClaims(tokenStr)
if err == nil {
adminID := claims.AdminID
level := claims.Level
exp := claims.ExpiresAt
}Opaque refresh tokens (non-JWT, for database storage):
// Generate opaque token (96 random hex chars)
plainToken, hashedToken, err := auth.GenerateOpaqueRefreshToken()
// Store hashedToken in DB, return plainToken to client
// Verify opaque token
receivedHash := auth.HashOpaqueToken(plainTokenFromClient)
// Compare receivedHash with hash stored in DBImplementation notes:
- The framework does NOT include built-in auth middleware or user/admin models
- JWT functions are STATELESS and database-agnostic - they only encode/decode data into/from token strings
- JWT tokens do NOT interact with database - you query the database separately using the ID from the token
- Column names and table structure are completely up to you - JWT only returns the data you encoded (userID, adminID, etc.)
- Implement authentication in a plugin (recommended) or in your own services
- Use the JWT utilities in
internal/authfor token generation and verification - Design your own middleware to validate tokens and inject user identity into
context.Context - Pass
context.Contextto service methods to propagate request identity
Example workflow:
// 1. Login handler - user provides credentials
func LoginHandler(c *gin.Context) {
// Verify credentials from YOUR database (any table structure)
var user YourUserModel // Could be "users", "admins", "accounts", etc.
db.Where("email = ?", email).First(&user) // YOUR column names
// Verify password (use your own hash method)
if !verifyPassword(user.Password, providedPassword) {
c.JSON(401, gin.H{"error": "invalid credentials"})
return
}
// Generate JWT with the user ID (from YOUR database)
token, _ := auth.SignAccessToken(user.ID) // Just needs an ID string
c.JSON(200, gin.H{"token": token})
}
// 2. Protected handler - verify token and get user
func ProtectedHandler(c *gin.Context) {
tokenStr := c.GetHeader("Authorization") // "Bearer xxx"
// Parse token - returns the ID you encoded earlier
userID, err := auth.ParseAccessToken(tokenStr)
if err != nil {
c.JSON(401, gin.H{"error": "invalid token"})
return
}
// Query YOUR database with YOUR schema
var user YourUserModel
db.First(&user, "id = ?", userID) // Use whatever column name you have
c.JSON(200, gin.H{"user": user})
}Key point: JWT is just a container for data. The actual database queries, column names, and table structures are entirely your responsibility.
Security recommendations
- Keep
AUTH_JWT_SECRET(or legacyJWT_SECRET) out of source control; use environment injection or secret managers. - Use short
JWT_ACCESS_EXP_SECONDSvalues for access tokens (recommended: 900 seconds / 15 minutes) and longerJWT_REFRESH_EXP_SECONDSfor refresh tokens. - Always serve authentication endpoints over HTTPS; set cookie flags
Secure,HttpOnly, andSameSitewhen using cookies. - Rotate signing keys and provide a migration/rotation plan (support key identifiers (
kid) in tokens if you add multiple keys).
Example: implementing auth in a plugin
- Create an auth plugin using
go run ./cmd/console plugin new --id auth - Add user/admin models in the plugin
- Implement login/register handlers and services
- Create auth middleware that validates tokens and injects user ID into context
- Register the middleware with appropriate priority in the plugin's
RegisterMiddleware()method
You can test JWT functions directly in your code or tests:
package mytest
import (
"testing"
"time"
"github.com/rolldone/donisgo/internal/auth"
)
func TestJWT(t *testing.T) {
// Set environment variable for testing
t.Setenv("AUTH_JWT_SECRET", "test-secret-key")
userID := "user123"
// Generate access token
token, err := auth.SignAccessToken(userID)
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
// Verify access token
parsedID, err := auth.ParseAccessToken(token)
if err != nil {
t.Fatalf("failed to parse token: %v", err)
}
if parsedID != userID {
t.Errorf("expected %s, got %s", userID, parsedID)
}
}
func TestJWTWithClaims(t *testing.T) {
t.Setenv("AUTH_JWT_SECRET", "test-secret-key")
// Generate token with custom claims
token, _, err := auth.GenerateAccessTokenWithLevel("admin123", "admin", 15*time.Minute)
if err != nil {
t.Fatal(err)
}
// Parse and verify claims
claims, err := auth.ParseAccessTokenClaims(token)
if err != nil {
t.Fatal(err)
}
if claims.AdminID != "admin123" {
t.Errorf("expected admin123, got %s", claims.AdminID)
}
if claims.Level != "admin" {
t.Errorf("expected admin, got %s", claims.Level)
}
}Testing
- Unit-test auth-related logic by mocking token generation/verification helpers. Look at
internal/mail/mailer_test.gofor examples of structure and patterns.
This project uses GORM (see internal/db/gorm.go) as the primary ORM. Below are connection, pooling, and migration notes to help setup and operate the database safely.
Connection
- DSN is composed from environment variables (
DB_TYPE,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_NAME,DB_SSLMODE). Example Postgres DSN format:
host=localhost port=5432 user=postgres dbname=app_db password=secret sslmode=disable
- The connection is created in
internal/db/gorm.go; prefer injecting the DB instance into services rather than using a global variable. - Transaction helper: see
internal/db/tx.goforWithTransaction(ctx, gdb, fn)which simplifies begin/commit/rollback patterns.
Pooling & tuning
- Recommended env vars:
DB_MAX_OPEN_CONNS,DB_MAX_IDLE_CONNS,DB_CONN_MAX_LIFETIME_SEC. Tune based on workload and connection limits of your DB server. - Monitor
pg_stat_activity(Postgres) or equivalent to avoid connection exhaustion when scaling workers or background jobs.
Transactions & context
- Pass
context.Contextand, when needed, a*gorm.DBtransaction instance from handlers into service functions so operations can participate in the same transaction. - Avoid long-lived DB transactions across user-facing requests; keep transactions short and deterministic.
Migrations
- SQL/Go migrations live in the
migrations/directory. Migration helper and CLI integration are available underinternal/console/migrate.go. - Run migrations locally via the console CLI. Example (from repo root):
go run ./cmd/console migrate- Note: GORM's
AutoMigratecan be useful for development but use structured migrations for production (reason: safer, reversible, explicit schema control).
Backup & maintenance
- Regularly backup the DB and test restores. Use read replicas for analytics/reporting to reduce load on primary.
- Apply schema changes during maintenance windows for high-traffic production systems.
The console (cmd/console) provides several commands for development and operations:
# Show all available commands
go run ./cmd/console --help
# Database migrations
go run ./cmd/console migrate [make|up|down|down-all|list]
# Plugin generator
go run ./cmd/console plugin new --id <plugin-id> [--template minimal|crud|middleware]
# Seed data
go run ./cmd/console seed [--plugin core|all|<plugin-id>]
# User management (if implemented)
go run ./cmd/console user [create|list|update|delete|get]See sections below for detailed usage of each command.
Migrations are managed via the console CLI exposed in cmd/console. Migration files live under migrations/{db_type} for core and plugins/{plugin_id}/migrations/{db_type} for plugins (where {db_type} is postgres, mysql, etc.).
Common commands (run from repo root):
# create a new migration pair (up/down) for core (or use --plugin <id>)
# files are timestamped automatically, e.g. 2026_05_03_150401_add_users_table.up.sql
go run ./cmd/console migrate make add_users_table
# apply pending migrations (core then plugins)
go run ./cmd/console migrate up
# apply pending migrations only for a specific plugin
go run ./cmd/console migrate --plugin myplugin up
# rollback the last migration (plugins rolled back last)
go run ./cmd/console migrate down
# rollback all migrations (plugins first, then core)
go run ./cmd/console migrate down-all
# show migration status per target
go run ./cmd/console migrate list
# override auto-detected DB type (useful for testing)
go run ./cmd/console migrate --db mysql upNotes:
- The migrate commands track state in DB tables
migrationsandmigration_targetscreated automatically on first run. - Migration files use a Laravel-style UTC timestamp prefix to keep ordering deterministic across plugins and environments.
- If a target is reported as
dirty, the CLI will refuse to continue; inspect the DB and migration files to resolve the issue (restore missing migration files or fix the database records), then cleardirtyinmigration_targets. - For production, prefer writing explicit SQL migration files and testing rollbacks on staging before applying to production.
This project uses GORM (*gorm.DB) as the shared database dependency and passes it to plugins through plugins.ServiceDeps. For file/object storage access, plugins also receive storage.Store from the same dependencies.
When you need transactional consistency across multiple service calls, prefer starting a transaction at the HTTP handler boundary and pass the transaction (*gorm.DB) explicitly into service methods. Also propagate the request context.Context into DB operations so cancellations/deadlines are honored.
Recommended handler pattern (Gin example):
func CreateItemHandler(c *gin.Context) {
ctx := c.Request.Context()
gdb := deps.DB
// start transaction
tx := gdb.Begin()
if tx.Error != nil {
c.JSON(500, gin.H{"error": "failed to start tx"})
return
}
// ensure rollback on panic or early return
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
// pass tx (with context) into service layer
tx = tx.WithContext(ctx)
if err := yourService.CreateItem(ctx, tx, req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if err := tx.Commit().Error; err != nil {
c.JSON(500, gin.H{"error": "failed to commit"})
return
}
committed = true
c.Status(201)
}Service method signature example (accept tx explicitly):
func (s *YourService) CreateItem(ctx context.Context, db *gorm.DB, req *CreateItemReq) error {
// use db (transaction) which already has ctx via db = db.WithContext(ctx)
if err := db.Create(&item).Error; err != nil {
return err
}
// call other DB ops using the same `db` to participate in the transaction
return nil
}Notes & best practices
- Prefer passing
*gorm.DBexplicitly rather than storing ephemeral transactions in global state. - Use
db.WithContext(ctx)so query cancellation and timeouts propagate. - Keep transactions short: perform only necessary DB work inside a transaction to avoid locking contention.
- Handle panics and ensure
Rollback()is called unlessCommit()succeeded. - For read-only handlers that do not need transactions, use the shared DB dependency directly (for example
deps.DB) withoutBegin(). Helper utility internal/db/tx.goexposesWithTransaction(ctx, gdb, fn)which wraps begin/commit/rollback and panic handling. Use it to simplify handlers:
err := db.WithTransaction(ctx, deps.DB, func(tx *gorm.DB) error {
if err := yourService.CreateItem(ctx, tx, req); err != nil {
return err
}
// other DB ops using tx...
return nil
})
if err != nil {
// handle error
}This project includes a pluggable architecture so features can be implemented as separate plugins. Core plugin-related files:
- Loader:
internal/pluginloader/loader.go— responsible for discovering and loading plugins at bootstrap. - Registry:
internal/plugins/registry.go— central registry where plugins register routes, services, and hooks. - Types & priorities:
internal/plugins/types.goandinternal/plugins/middleware_priorities.go— define plugin interfaces and middleware ordering.
Key concepts
- Discovery: plugins are created under the
plugins/directory. Each plugin has its own folder with optionalmigrations/, handlers, and registration code.plugins/already ships two framework packages:pages(built-in, see below) andplugin_registry(inter-plugin contracts). - Registration: plugins register themselves with the registry during application bootstrap in
cmd/server/main.goandcmd/console/main.go; this allows them to add routes, middleware, and service hooks. - Service deps: plugins receive shared dependencies through
plugins.ServiceDeps, currentlyDB *gorm.DBandStore storage.Store. - Middleware ordering: plugin middleware is executed according to priorities defined in
internal/plugins/middleware_priorities.go. Valid targets areglobal,admin, andapi. - Migrations: plugins can include DB migrations under
plugins/{plugin_id}/migrations/{db_type}(e.g.,migrations/postgres/); the console migrate commands detect and apply plugin migrations in the configured order.
Integration notes
- To enable a plugin, create it in the
plugins/directory, ensure the plugin is registered in bothcmd/server/main.goandcmd/console/main.go(see Plugin registration quickstart below). - Plugins should be written to be defensive: validate inputs, avoid global state, and return errors that the core can log and surface gracefully.
- Hot-reload is not assumed; plugins are loaded at bootstrap. For runtime reloading, add explicit support in the loader and consider concurrency/consistency implications.
Testing & safety
- Test plugins in isolation by running their handlers/services against a test instance of the registry and a sandbox DB.
donisgo memiliki plugin "bawaan"/paten yang otomatis didaftarkan oleh framework — user TIDAK perlu (dan tidak boleh) mendaftarkannya manual:
pages(plugins/pages/plugin.go) — melayani SPA dari./sub_app/webapp/dist, wildcard/catch-all viarouter.NoRoute, dan proxy homepage opsional (HOMEPAGE_URL). Selalu di-register PALING AKHIR olehpluginloader.RegisterLastPlugins():- Server: dipanggil dari
internal/app/bootstrap.go(attachPlugins) SETELAH plugin user didaftarkan. - Console: dipanggil dari
internal/console/root.go(Run). Karena ia memegangrouter.NoRoute, urutan terakhir ini WAJIB — semua route spesifik (api/admin/health) dari plugin lain harus menang duluan.⚠️ Plugin ini PATEN — JANGAN dihapus. Registrasinya otomatis viaplugins.EnsurePluginLast(idempotent + dedup), jadi pages selalu tepat satu dan di akhir, apa pun yang didaftarkan user dicmd/server/main.go/cmd/console/main.go.
- Server: dipanggil dari
plugins/plugin_registry menyediakan mekanisme berbagi interface fungsi antar
plugin tanpa circular import:
- Definisikan interface contract di
plugin_registry/registry.go(mis.ChatProvider). - Plugin PROVIDER mendaftarkan implementasinya via
Register<X>Provider(...)diRegisterServices(deps). - Plugin CONSUMER memanggil delegation function (mis.
SendMessage(...)). - Keduanya hanya import
plugin_registry— tidak saling import. - Setiap delegation function punya nil-guard: tanpa provider terdaftar, kembalikan default aman (bukan panic).
Panduan menambah contract baru + contoh EchoProvider ada di komentar
plugins/plugin_registry/registry.go. Error bersama antar plugin
didefinisikan di plugins/plugin_registry/errors.go (dideteksi via
errors.As).
The fastest way to create a new plugin is using the console command:
# Generate a minimal plugin
go run ./cmd/console plugin new --id my-plugin
# Generate a CRUD plugin with handlers and services
go run ./cmd/console plugin new --id my-plugin --template crud
# Generate a middleware-focused plugin
go run ./cmd/console plugin new --id my-plugin --template middleware
# With custom display name
go run ./cmd/console plugin new --id my-plugin --name "My Awesome Plugin"
# Skip console command stub
go run ./cmd/console plugin new --id my-plugin --no-consoleTemplate options:
minimal(default) - Basic plugin with a health check handlercrud- Includes CRUD handlers and service layer for resource managementmiddleware- Focuses on middleware with sample middleware implementation
Generated structure:
plugins/my_plugin/plugin.go- main plugin file implementingplugins.Plugininterfaceplugins/my_plugin/handlers/- HTTP handler files (health.go, resource.go, etc.)plugins/my_plugin/migrations/postgres/- migration files (000001_init.up.sql, 000001_init.down.sql)plugins/my_plugin/services/- service layer (CRUD template only)plugins/my_plugin/middleware/- middleware implementations (middleware template only)
After generating, you must register the plugin in both:
cmd/server/main.go- for HTTP server routes and middlewarecmd/console/main.go- for console commands (migrations, seeds, custom commands)
To manually create a plugin or understand the structure:
-
Create a plugin package under
plugins/<plugin_id>/in your workspace. -
Implement the
plugins.Plugininterface (seeinternal/plugins/types.go). Minimal responsibilities:ID() string— return plugin idRegisterServices(deps plugins.ServiceDeps) error— initialize plugin services using shared DB/storage depsRegisterMiddleware() []plugins.MiddlewareDescriptor— provide middleware descriptors (Target:global,admin,api)RegisterRoutes(router *gin.Engine, admin *gin.RouterGroup, api *gin.RouterGroup) error— attach routesSeed() error— optional seed dataConsoleCommands() []*cobra.Command— optional CLI commands
-
Register the plugin in both
cmd/server/main.goandcmd/console/main.go:
cmd/server/main.go:
import (
// ... other imports
"github.com/rolldone/donisgo/internal/plugins"
myplugin "github.com/rolldone/donisgo/plugins/my_plugin" // note: use underscore for import path
)
func main() {
err := app.Run(app.Options{
RegisterPlugins: func() {
plugins.RegisterPlugins([]plugins.Plugin{
myplugin.New(),
})
},
})
// ...
}cmd/console/main.go:
import (
// ... other imports
"github.com/rolldone/donisgo/internal/console"
"github.com/rolldone/donisgo/internal/plugins"
myplugin "github.com/rolldone/donisgo/plugins/my_plugin" // note: use underscore for import path
)
func main() {
console.RegisterAdditionalPlugins([]plugins.Plugin{myplugin.New()})
console.Execute()
}- Place DB migrations under
plugins/<plugin_id>/migrations/<db_type>if needed (e.g.,plugins/myplugin/migrations/postgres/).
Minimal plugin skeleton (example file: plugins/myplugin/plugin.go):
package myplugin
import (
"github.com/gin-gonic/gin"
"github.com/spf13/cobra"
"github.com/rolldone/donisgo/internal/plugins"
)
type MyPlugin struct{}
func New() plugins.Plugin { return &MyPlugin{} }
func (p *MyPlugin) ID() string { return "myplugin" }
func (p *MyPlugin) RegisterServices(deps plugins.ServiceDeps) error {
// deps.DB dan deps.Store tersedia di sini
return nil
}
func (p *MyPlugin) RegisterMiddleware() []plugins.MiddlewareDescriptor {
return []plugins.MiddlewareDescriptor{
{Name: "myplugin.log", Target: "global", Priority: 100, Handler: func(c *gin.Context) { /*...*/ c.Next() }},
}
}
func (p *MyPlugin) RegisterRoutes(router *gin.Engine, admin *gin.RouterGroup, api *gin.RouterGroup) error {
admin.GET("/myplugin/ping", func(c *gin.Context) { c.JSON(200, gin.H{"pong": true}) })
_ = router
_ = api
return nil
}If you want a plugin to register static routes that serve files from the local storage root (same approach used in plugins/catalog), add the following check inside RegisterRoutes. It uses the Store instance provided in RegisterServices and only registers the routes when the store is a *storage.LocalStore:
func (p *Plugin) RegisterRoutes(router *gin.Engine, admin *gin.RouterGroup, api *gin.RouterGroup) error {
if p.service == nil {
return fmt.Errorf("myplugin: service not registered")
}
// Jika storage lokal aktif, daftarkan static routes mirip catalog
if localStore, ok := p.service.Store.(*storage.LocalStore); ok {
router.Static("/assets/products", localStore.GetRoot()+"/products")
router.Static("/assets/businesses", localStore.GetRoot()+"/businesses")
}
}Place this code in your plugin's RegisterRoutes so the application serves /assets/* paths from the configured STORAGE_ROOT when using local storage. This keeps the behavior identical to the catalog plugin and avoids touching core bootstrap code.
func (p *MyPlugin) Seed() error { return nil }
func (p *MyPlugin) ConsoleCommands() []*cobra.Command { return nil }
Notes:
-
Choose middleware
Prioritycarefully so plugins integrate predictably with core middleware. -
Keep plugins isolated and avoid global mutable state.
-
Register plugins before
plugins.AttachMiddlewareis called (bootstrap handles this viaapp.Runoptions). -
Limit the privileges of plugin-executed operations (DB, external APIs) where possible.
cmd/servercalls bootstrap ininternal/appto initialize:- Configuration from environment variables
- Database connection (GORM)
- Storage service (
localors3) - KeyDB/Redis connection (for flash messages)
- Gin router with CORS configuration
- Static
/assetsroute when local storage is active
- Core plugins are loaded via
internal/pluginloader, then user-provided plugins registered incmd/server/main.go - Plugin services are registered with shared deps (
plugins.ServiceDeps{DB, Store}) - Plugin middleware are attached to router groups (global, admin, api) based on priority
- Plugin routes are registered
- Swagger documentation routes are registered (if enabled)
- HTTP server is started on configured port
- Client HTTP request arrives at the server binary (
cmd/server). - Router matches the route and triggers the middleware chain.
- Global middleware execute in priority order:
- Gin's default middlewares (Logger, Recovery)
- CORS middleware (if configured via
CORS_ALLOWED_ORIGINS) - Plugin middleware (registered according to priorities defined in
internal/plugins/middleware_priorities.go) - Note: Authentication middleware is NOT included by default — implement in a plugin
- After middleware, the matched handler runs (plugin-registered handlers or custom handlers).
- Handler calls into plugin services or custom code for business logic, DB interactions, storage, or cache usage.
- Shared dependencies come from bootstrap: GORM (
internal/db/gorm.go), storage (internal/storage), and optional KeyDB (internal/keydb). - Handler serializes the response (JSON/HTML) and returns it to the client.
- Plugin hooks or response middleware may modify the response before it is sent.
- Plugins are loaded at bootstrap and may register routes, middleware, and service hooks.
- Middleware ordering for plugins is controlled by
internal/plugins/middleware_priorities.go. - Plugins integrate with core services via the registry in
internal/plugins/registry.go.
Plugin-first approach:
- Implement features (auth, user management, business logic) as plugins rather than modifying core files
- Use
go run ./cmd/console plugin new --id <feature-name>to generate plugin scaffolds - Keep plugins isolated and testable - each plugin should be self-contained
Code organization:
- Add routes/handlers via plugins (recommended) or by modifying
internal/app/bootstrap.go - Register middleware with explicit priority so execution order is predictable
- Keep service layer decoupled from HTTP layer — services should accept
context.Contextand repository interfaces - Prefer plugin-local services initialized from
plugins.ServiceDepsinstead of extending core structs unless truly necessary
Database & transactions:
- Use
context.Contextto pass request identity and cancellation signals into services - Pass
*gorm.DBtransactions explicitly to service methods (see Transactions & context patterns section) - Use the transaction helper
db.WithTransaction()ininternal/db/tx.goto simplify error handling
Testing:
- Mock DB and services for unit testing; see
internal/mail/mailer_test.gofor example patterns - Test plugins in isolation by providing test
*gorm.DBand, when needed, a fakestorage.Store - Use
go run ./cmd/console migrate --db <type> upto run migrations in test databases
Security:
- Implement authentication middleware in a plugin and register with appropriate priority
- Never commit secrets to version control - use environment variables and secret managers
- Validate and sanitize all user inputs in handlers before passing to services
- Server entry:
cmd/server/main.go - Console entry:
cmd/console/main.go - Bootstrap:
internal/app/bootstrap.go - Admin services:
internal/admin/services/services.go - Plugin loader:
internal/pluginloader/loader.go - Plugin registry:
internal/plugins/registry.go - Plugin types:
internal/plugins/types.go - DB (GORM):
internal/db/gorm.go - DB transactions:
internal/db/tx.go - Auth utilities (JWT):
internal/auth/jwt.go - Storage:
internal/storage/config.go - Console commands:
internal/console/ - Mailer:
internal/mail/mailer.go - KeyDB client:
internal/keydb/client.go
Below is a Mermaid diagram that visualizes the main request path and extension points.
flowchart LR
Client[Client HTTP] --> Server[cmd/server]
Server --> Router[Router]
Router --> MWChain[Middleware Chain]
subgraph Middlewares
Core[Core middleware: logging, recovery, CORS, request-id]
Auth[Auth middleware: internal/auth]
PluginMW[Plugin middleware (priority-based)]
end
MWChain --> Core --> Auth --> PluginMW --> Handler[Handler]
Handler --> Service[Service layer: internal/*/services]
Service --> DB[GORM (internal/db)]
DB --> Service
Service --> Handler
Handler --> Response[Response -> Client]
PluginMW --> Hooks[Plugin hooks / response modifiers]
Hooks --> Response