Microservice-Ready Architecture: Decoupling User Lookup

The Problem You Identified ❌

Current Implementation (Tight Coupling):
// Every module depends on user module
import "github.com/waynecheah/go-core/internal/modules/user/application/port"

type Container struct {
    UserService ports.UserService  // ❌ Direct dependency on user module!
}
Issues:
  1. ❌ Every new module needs user module code
  2. ❌ Can’t deploy modules independently
  3. ❌ Circular dependency risk
  4. ❌ Not true microservice architecture
  5. ❌ Violates Dependency Inversion Principle
You’re absolutely right - this is NOT loose coupling!

The Solution: Shared Kernel + Anti-Corruption Layer ✅

Shared kernel + anti-corruption layer

Implementation Steps

Step 1: Create Shared Kernel Interface

File: internal/shared/application/port/user_lookup.go
package port

import "context"

// UserInfo represents minimal user information (shared kernel)
type UserInfo struct {
    ID    string
    Name  string
    Email string
    Roles []string
}

// UserLookupService is the shared contract
// NO dependency on any specific module!
type UserLookupService interface {
    GetUserInfo(ctx context.Context, userID string) (*UserInfo, error)
    GetUserInfoBatch(ctx context.Context, userIDs []string) (map[string]*UserInfo, error)
}
Key Points:
  • ✅ Lives in internal/shared/application/port (shared kernel)
  • ✅ No dependencies on any module
  • ✅ Minimal interface (only what’s needed)
  • ✅ Can be implemented by ANY service (user, LDAP, external API, etc.)

Step 2: Create Anti-Corruption Layer in User Module

File: internal/modules/user/adapter/outbound/external/user_lookup_adapter.go
package adapters

import (
    sharedPort "github.com/waynecheah/go-core/internal/shared/application/port"
    "github.com/waynecheah/go-core/internal/modules/user/application/port"
)

// UserLookupAdapter adapts user module to shared kernel interface
type UserLookupAdapter struct {
    userService port.UserService  // User module's internal service
}

func NewUserLookupAdapter(userService port.UserService) sharedPort.UserLookupService {
    return &UserLookupAdapter{userService: userService}
}

// GetUserInfo implements sharedPort.UserLookupService
func (a *UserLookupAdapter) GetUserInfo(ctx context.Context, userID string) (*sharedPort.UserInfo, error) {
    // Call user module's service
    user, err := a.userService.Get(ctx, userID)
    if err != nil {
        return nil, err
    }

    // Map to shared kernel model
    return &sharedPort.UserInfo{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
        Roles: user.Roles,
    }, nil
}
Key Points:
  • ✅ Lives in user module (owns the adaptation)
  • ✅ Implements shared kernel interface
  • ✅ Maps between user module’s domain and shared kernel
  • ✅ Other modules don’t know about this adapter

Step 3: Update Container

File: internal/app/container.go
package app

import (
    sharedPort "github.com/waynecheah/go-core/internal/shared/application/port"  // ← Shared kernel only!
    // NO import of user module!
)

type Container struct {
    Config      *config.Config
    Logger      *logger.Logger
    DB          *mongo.Database
    EventBus    *events.EventBus
    UserLookup  sharedPort.UserLookupService  // ← Shared interface, not module-specific!
}
Key Points:
  • ✅ Only depends on shared kernel
  • ✅ No dependency on user module
  • ✅ Can be implemented by any service

Step 4: Update Middleware

File: internal/api/http/middleware/audit_context.go
package middleware

import (
    sharedPort "github.com/waynecheah/go-core/internal/shared/application/port"  // ← Shared kernel only!
    // NO import of user module!
)

func AuditContextMiddleware(userLookup sharedPort.UserLookupService) echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            // ... existing code ...

            userInfo, err := userLookup.GetUserInfo(ctx, userID)
            if err == nil && userInfo != nil {
                userName = userInfo.Name  // ✅ Uses shared kernel interface!
            }
        }
    }
}
Key Points:
  • ✅ Only depends on shared kernel
  • ✅ No dependency on user module
  • ✅ Works with ANY implementation of UserLookupService

Step 5: Wire It Up in Main

File: cmd/server/main.go
import (
    "github.com/waynecheah/go-core/internal/modules/user"
    userAdapters "github.com/waynecheah/go-core/internal/modules/user/adapter/outbound/external"  // ← Only here!
)

func main() {
    container := app.NewContainer(cfg, l, db, client, eventBus)

    // Initialize user module
    userModule := user.NewModule(container)

    // Create adapter and set in container
    // This is the ONLY place that knows about the adapter
    container.UserLookup = userAdapters.NewUserLookupAdapter(userModule.Service)

    // Initialize other modules
    leaveModule := leave.NewModule(container)  // ← No dependency on user module!
}
Key Points:
  • ✅ Only main.go knows about the adapter
  • ✅ Modules are completely decoupled
  • ✅ Can swap implementations easily

Benefits of This Approach

1. True Loose Coupling

Before (Tight Coupling):
Leave Module → User Module Ports → User Module

After (Loose Coupling):
Leave Module → Shared Kernel ← User Module Adapter
No direct dependency between modules!

2. Microservice Ready

Each module can be deployed independently:
// In microservice mode, replace with gRPC client
type UserServiceGRPCClient struct {
    client userpb.UserServiceClient
}

func (c *UserServiceGRPCClient) GetUserInfo(ctx context.Context, userID string) (*corePorts.UserInfo, error) {
    resp, err := c.client.GetUser(ctx, &userpb.GetUserRequest{UserId: userID})
    if err != nil {
        return nil, err
    }

    return &corePorts.UserInfo{
        ID:    resp.Id,
        Name:  resp.Name,
        Email: resp.Email,
        Roles: resp.Roles,
    }, nil
}

// In main.go:
if cfg.MicroserviceMode {
    container.UserLookup = NewUserServiceGRPCClient(userServiceURL)
} else {
    container.UserLookup = adapters.NewUserLookupAdapter(userModule.Service)
}

3. Easy to Test

// Mock implementation for testing
type MockUserLookup struct{}

func (m *MockUserLookup) GetUserInfo(ctx context.Context, userID string) (*sharedPort.UserInfo, error) {
    return &sharedPort.UserInfo{
        ID:   userID,
        Name: "Test User",
    }, nil
}

// In tests:
middleware := AuditContextMiddleware(&MockUserLookup{})

4. Swappable Implementations

// Can use different implementations:
container.UserLookup = userAdapters.NewUserLookupAdapter(userModule.Service) // MongoDB
container.UserLookup = NewLDAPUserLookup(ldapClient)                         // LDAP
container.UserLookup = NewAuth0UserLookup(auth0Client)                       // Auth0
container.UserLookup = NewUserServiceGRPCClient(grpcConn)                    // gRPC

Dependency Graph

Before (Tight Coupling):

main.go
  ├─→ user module
  └─→ leave module
        └─→ user module  ❌ Circular dependency risk!

After (Loose Coupling):

main.go
  ├─→ shared kernel (interfaces only)
  ├─→ user module
  │     └─→ shared kernel
  │     └─→ user adapter (implements shared kernel)
  └─→ leave module
        └─→ shared kernel  ✅ No dependency on user module!

Migration Path

Current State:

// Container
UserService ports.UserService  // ❌ Tight coupling

// Middleware
func AuditContextMiddleware(userService ports.UserService)  // ❌ Depends on user module

Target State:

// Container
UserLookup corePorts.UserLookupService  // ✅ Shared kernel interface

// Middleware
func AuditContextMiddleware(userLookup corePorts.UserLookupService)  // ✅ No module dependency

Migration Steps:

  1. ✅ Create shared kernel interface (internal/shared/application/port/user_lookup.go)
  2. ✅ Create adapter in user module (internal/modules/user/adapter/outbound/external/user_lookup_adapter.go)
  3. ✅ Update container to use shared interface
  4. ✅ Update middleware to use shared interface
  5. ✅ Update main.go to wire adapter
  6. ✅ Update leave module to use container.UserLookup

Answer to Your Question

“Does this mean every new microservice module we build in the future needs to include this user service in user module? Is this still loose coupled design?”
Answer:

With Current Implementation (Before Refactor): NO

  • Every module would depend on user module
  • NOT loose coupling
  • NOT microservice-ready

With Shared Kernel Approach (After Refactor): YES

  • Modules depend on shared kernel interface (just a contract)
  • User module provides ONE implementation
  • Other implementations possible (gRPC, LDAP, etc.)
  • TRUE loose coupling
  • Microservice-ready
Key Insight: The shared kernel is just interfaces and DTOs - no implementation. It’s like depending on http.Handler interface in Go - everyone uses it, but no one depends on a specific implementation.

Real-World Example: How Google Does It

Google’s internal services use a similar pattern:
Shared Kernel (Stubby RPC interfaces)
    ↑                    ↑
    │                    │
User Service      Calendar Service
(implements)        (depends on)
Calendar Service doesn’t depend on User Service code. It depends on the Stubby RPC interface (shared kernel). User Service implements that interface.

Summary

  • Shared Kernel - Common interfaces, no implementation
  • Anti-Corruption Layer - Adapts module to shared kernel
  • Dependency Inversion - Depend on abstractions, not concretions
  • Microservice Ready - Can swap implementations (local, gRPC, HTTP)
  • Truly Decoupled - Modules don’t know about each other
This IS loose coupling! 🎉