Separation of Concerns Usage Guide¶
When to Apply This Pattern¶
Quick Start
This guide is part of a modular documentation set. Refer to related guides in the navigation for complete context.
Always:
- CLI tools
- API handlers
- Workflow orchestrators
- Service layers
Especially when:
- Multiple concerns in one function
- Testing requires external systems
- Changes ripple across unrelated code
- New team members struggle to understand flow
Common Mistakes¶
Mistake 1: Over-Separation¶
// Too granular
func getFirstName(user User) string { return user.FirstName }
func getLastName(user User) string { return user.LastName }
func formatName(first, last string) string { return first + " " + last }
// Reasonable
func getFullName(user User) string {
return fmt.Sprintf("%s %s", user.FirstName, user.LastName)
}
Separation is about logical concerns, not individual operations.
Mistake 2: Premature Abstraction¶
Don't separate concerns that don't exist yet. Wait until you have two different concerns before splitting.
Mistake 3: Wrong Boundaries¶
// Bad: cuts across natural boundaries
func parseAndValidate(path string) (*Config, error) // Parser + validator mixed
// Good: natural boundaries
func parse(path string) (*Config, error)
func validate(config *Config) error
Anti-Patterns¶
❌ Business Logic in Command Handlers¶
// Bad: Business logic trapped in CLI layer
func NewDeployCommand() *cobra.Command {
return &cobra.Command{
Use: "deploy",
RunE: func(cmd *cobra.Command, args []string) error {
namespace, _ := cmd.Flags().GetString("namespace")
image, _ := cmd.Flags().GetString("image")
// ❌ Validation logic in CLI handler
if image == "" || namespace == "" {
return fmt.Errorf("missing required flags")
}
// ❌ Kubernetes logic in CLI handler
config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)
clientset, _ := kubernetes.NewForConfig(config)
// ❌ Deployment creation in CLI handler
deployment := &appsv1.Deployment{/* ... */}
_, err := clientset.AppsV1().Deployments(namespace).Create(
context.Background(), deployment, metav1.CreateOptions{},
)
return err
},
}
}
Problems:
- Cannot test without Kubernetes cluster
- Cannot reuse from API or CronJob
- Cannot mock Kubernetes client for testing
- Violates single responsibility principle
Fix: Move business logic to pkg/deployer, keep only CLI concerns in cmd/.
❌ Tight Coupling to CLI Framework¶
// Bad: Passing Cobra command to business logic
package deployer
func Deploy(cmd *cobra.Command, namespace string) error {
// ❌ Business logic depends on Cobra
verbose, _ := cmd.Flags().GetBool("verbose")
if verbose {
fmt.Println("Starting deployment...")
}
// Deployment logic...
}
Problems:
- Business logic tied to Cobra
- Cannot use deployer from non-CLI contexts
- Tests require Cobra command setup
Fix: Pass plain values, not framework types. Use interfaces for output.
// Good: Framework-agnostic interface
package deployer
type Logger interface {
Info(msg string)
Error(msg string)
}
func Deploy(namespace string, logger Logger) error {
logger.Info("Starting deployment...")
// Deployment logic...
}
❌ Wrong Layer Boundaries¶
// Bad: Cutting across natural boundaries
package app
// ❌ Parser and validator mixed
func LoadAndValidateConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
// Validation mixed with parsing
if config.Name == "" {
return nil, fmt.Errorf("name required")
}
return &config, nil
}
Problems:
- Cannot test validation independently
- Cannot reuse parser with different validators
- Changes to validation require touching parser
Fix: Separate into distinct responsibilities.
// Good: Natural boundaries
package parser
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
package validator
func Validate(config *Config) error {
if config.Name == "" {
return fmt.Errorf("name required")
}
return nil
}
❌ Missing Abstraction at Boundaries¶
// Bad: Direct dependency on concrete implementation
package orchestrator
import "example.com/internal/k8s"
type Orchestrator struct {
// ❌ Concrete type, cannot mock
deployer *k8s.Deployer
}
func (o *Orchestrator) Run() error {
return o.deployer.Deploy()
}
Problems:
- Cannot mock deployer for testing
- Cannot swap implementations
- Tight coupling to Kubernetes
Fix: Use interfaces at boundaries.
// Good: Interface at boundary
package orchestrator
type Deployer interface {
Deploy(ctx context.Context) error
}
type Orchestrator struct {
deployer Deployer // Interface, easily mocked
}
func (o *Orchestrator) Run(ctx context.Context) error {
return o.deployer.Deploy(ctx)
}
cmd/ vs pkg/ Structure Examples¶
CLI Tools¶
Correct structure for deployment CLI:
deploy-tool/
├── cmd/
│ └── deploy/
│ └── main.go # Cobra setup, flag parsing
├── pkg/
│ ├── deployer/
│ │ ├── deployer.go # Deployment orchestration
│ │ └── deployer_test.go # Unit tests (no cluster)
│ ├── validator/
│ │ ├── validator.go # Config validation
│ │ └── validator_test.go # Pure logic tests
│ └── k8s/
│ ├── client.go # Kubernetes client wrapper
│ └── fake.go # Fake client for testing
└── internal/
└── config/
└── loader.go # Config file parsing
What goes where:
| Concern | Location | Example |
|---|---|---|
| Flag definitions | cmd/ |
cmd.Flags().StringVar(&opts.Namespace, "namespace", "default", "") |
| Output formatting | cmd/ |
fmt.Printf("Deployed %s\n", result.Name) |
| Exit codes | cmd/ |
os.Exit(1) or return err from RunE |
| Validation logic | pkg/validator/ |
func Validate(config *Config) error |
| Deployment logic | pkg/deployer/ |
func (d *Deployer) Deploy(ctx context.Context) error |
| Kubernetes API calls | pkg/k8s/ |
Wrapped client interface |
| Config parsing | internal/config/ |
func Load(path string) (*Config, error) |
Workflow Orchestration¶
Correct structure for multi-step workflow:
workflow-tool/
├── cmd/
│ └── run/
│ └── main.go # CLI entry point
├── pkg/
│ ├── orchestrator/
│ │ ├── orchestrator.go # Workflow coordination
│ │ └── orchestrator_test.go # Integration tests
│ ├── steps/
│ │ ├── validate.go # Step: Validation
│ │ ├── build.go # Step: Build
│ │ ├── deploy.go # Step: Deploy
│ │ └── verify.go # Step: Verification
│ └── executor/
│ ├── executor.go # Step execution framework
│ └── mock.go # Mock executor for testing
Orchestrator delegates to steps:
// pkg/orchestrator/orchestrator.go
type Orchestrator struct {
executor executor.Executor
steps []Step
}
func (o *Orchestrator) Run(ctx context.Context) error {
for _, step := range o.steps {
if err := o.executor.Execute(ctx, step); err != nil {
return fmt.Errorf("step %s failed: %w", step.Name(), err)
}
}
return nil
}
Real-World Example¶
See Go CLI Architecture for complete implementation of the orchestrator pattern with testing strategies.
Related Patterns¶
- Pattern Overview: Core concepts and CLI orchestrator pattern
- Implementation Techniques: Testing, interfaces, dependency injection
- Hub and Spoke: Distributed version of orchestration
- Fail Fast: Error handling at boundaries
- Prerequisite Checks: Validation separation
- Three-Stage Design: Discovery → Execution → Summary pattern
Each component does one thing well. Changes are isolated. Tests run in milliseconds. The system is maintainable.