Why Build Custom ERP Modules
Standard ERP modules are designed for the average company. Your company isn't average.
You have unique workflows, specific regulations (especially in Indonesia — PPN, PPh23, perda lokal), and processes that don't fit any standard module. Instead of forcing your business into a rigid ERP mold, build custom modules that fit your business like a glove.
The Architecture
Here's the modular architecture I use for custom ERP modules in Go:
// Module interface that every ERP module must implement
type ERPModule interface {
Name() string
Version() string
Init(db *sqlx.DB, aiClient *AIClient) error
RegisterRoutes(api fiber.Router)
RegisterHooks(hooks *HookRegistry)
HealthCheck(ctx context.Context) error
}
// Base module with common functionality
type BaseModule struct {
DB *sqlx.DB
AI *AIClient
Logger *zerolog.Logger
Config *ModuleConfig
}
func (b *BaseModule) HealthCheck(ctx context.Context) error {
return b.DB.PingContext(ctx)
}
Example: AI-Powered Purchase Module
Here's a complete purchase module with AI-powered features:
type PurchaseModule struct {
BaseModule
}
func (m *PurchaseModule) Name() string { return "purchase" }
func (m *PurchaseModule) Version() string { return "1.0.0" }
func (m *PurchaseModule) RegisterRoutes(api fiber.Router) {
purchase := api.Group("/purchase")
purchase.Post("/orders", m.CreatePurchaseOrder)
purchase.Get("/orders", m.ListPurchaseOrders)
purchase.Post("/invoices/process", m.ProcessInvoice)
purchase.Post("/suggest-vendor", m.SuggestVendor)
}
// AI-powered vendor suggestion
func (m *PurchaseModule) SuggestVendor(c *fiber.Ctx) error {
var req VendorSuggestionRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request"})
}
// Get historical data
history, err := m.getPurchaseHistory(c.Context(), req.ItemID)
if err != nil {
return err
}
// Build AI prompt with business context
prompt := fmt.Sprintf(`
Based on our purchase history, suggest the best vendor for:
Item: %s
Quantity: %d
Required by: %s
Purchase history:
%s
Consider: price, delivery time, quality rating, and reliability.
Recommend the top 3 vendors with reasons.
`, req.ItemName, req.Quantity, req.RequiredBy, formatHistory(history))
suggestion, err := m.AI.Complete(c.Context(), prompt)
if err != nil {
return err
}
return c.JSON(fiber.Map{
"suggestion": suggestion,
"source": "ai_analysis",
"confidence": calculateConfidence(history),
})
}
Database Schema Pattern
Every module gets its own schema prefix to avoid conflicts:
-- Purchase module tables
CREATE SCHEMA purchase;
CREATE TABLE purchase.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_number VARCHAR(20) UNIQUE NOT NULL,
vendor_id UUID NOT NULL REFERENCES core.vendors(id),
status VARCHAR(20) DEFAULT 'draft',
total_amount DECIMAL(15, 2) NOT NULL,
tax_amount DECIMAL(15, 2) DEFAULT 0,
notes TEXT,
ai_suggestion JSONB,
created_by UUID REFERENCES core.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE purchase.order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES purchase.orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES core.products(id),
quantity DECIMAL(10, 2) NOT NULL,
unit_price DECIMAL(15, 2) NOT NULL,
total_price DECIMAL(15, 2) GENERATED ALWAYS AS (quantity * unit_price) STORED
);
-- AI audit trail
CREATE TABLE purchase.ai_decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
decision_type VARCHAR(50) NOT NULL,
prompt_used TEXT,
ai_response TEXT,
confidence DECIMAL(3, 2),
human_override BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Module Registration and Discovery
// main.go - Module registration
func main() {
db := initDB()
aiClient := initAI()
// Register all modules
modules := []ERPModule{
&purchase.PurchaseModule{},
&inventory.InventoryModule{},
&finance.FinanceModule{},
&hr.HRModule{},
}
app := fiber.New()
api := app.Group("/api/v1")
for _, mod := range modules {
if err := mod.Init(db, aiClient); err != nil {
log.Fatal().Err(err).Str("module", mod.Name()).Msg("module init failed")
}
mod.RegisterRoutes(api)
log.Info().Str("module", mod.Name()).Str("version", mod.Version()).Msg("module loaded")
}
app.Listen(":8080")
}
Testing Modules
Each module is independently testable:
func TestPurchaseModule_CreateOrder(t *testing.T) {
db := setupTestDB(t)
aiClient := setupMockAI(t)
mod := &PurchaseModule{}
mod.Init(db, aiClient)
app := fiber.New()
mod.RegisterRoutes(app.Group("/api"))
// Create order
body := `{"vendor_id": "v1", "items": [{"product_id": "p1", "quantity": 10, "unit_price": 50000}]}`
req := httptest.NewRequest("POST", "/api/purchase/orders", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, 201, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.NotEmpty(t, result["id"])
}
Deployment
Each module is a separate Go package, compiled into a single binary:
# Build with all modules
go build -o erp-server ./cmd/server
# Or build with specific modules only
MODULES=purchase,inventory go build -o erp-server ./cmd/server
# Docker
docker build -t erp-server:latest .
docker run -p 8080:8080 erp-server:latest
Key Takeaways
- Modular by design. Each module is independent, testable, and replaceable.
- AI is a service, not a feature. Inject AI capability into modules through dependency injection.
- Schema isolation. Each module owns its database schema. No conflicts.
- Audit everything. AI decisions must be logged and auditable.
- Go's type safety catches integration bugs at compile time, not in production.
The Bottom Line
Building custom ERP modules in Go with AI isn't rocket science. It's disciplined software engineering applied to business problems. Clean architecture, proper testing, and AI where it actually adds value — that's the formula.