CDK8S Patterns#

Understanding construct patterns and best practices in cdk8s-mailu.

Introduction#

CDK8S (Cloud Development Kit for Kubernetes) allows you to define Kubernetes applications using familiar programming languages. cdk8s-mailu leverages CDK8S patterns to provide a type-safe, testable, and maintainable way to deploy Mailu.

Core Concepts#

Constructs#

What is a Construct?

A construct is a composable building block that encapsulates one or more Kubernetes resources. In cdk8s-mailu, each Mailu component is implemented as a construct.

export class AdminConstruct extends Construct {
  constructor(scope: Construct, id: string, config: AdminConfig) {
    super(scope, id);

    // Create Deployment
    new Deployment(this, 'deployment', { ... });

    // Create Service
    new Service(this, 'service', { ... });
  }
}

Benefits:

  • Encapsulation - Hide complex resource definitions

  • Reusability - Use the same construct in multiple charts

  • Testability - Unit test each construct independently

  • Composability - Combine constructs to build applications

Charts#

What is a Chart?

A chart is a top-level construct that represents a complete Kubernetes application. It contains all the constructs and resources needed for deployment.

export class MailuChart extends Chart {
  constructor(scope: Construct, id: string, props: MailuChartProps) {
    super(scope, id, props);

    // Create namespace
    const ns = new Namespace(this, 'namespace', { ... });

    // Create constructs
    new FrontConstruct(this, 'front', config);
    new AdminConstruct(this, 'admin', config);
    // ...
  }
}

Characteristics:

  • Extends Chart from cdk8s

  • Entry point for synthesis

  • Manages shared resources (namespace, configmaps)

  • Coordinates multiple constructs

Apps#

What is an App?

An app is the root of your CDK8S application. It contains one or more charts and is responsible for synthesizing Kubernetes manifests.

const app = new App();
new MailuChart(app, 'mailu', { ... });
app.synth();  // Generate YAML files

Design Patterns#

1. Configuration Object Pattern#

Pattern: Pass a single configuration object instead of many parameters

// Good: Single configuration object
new AdminConstruct(this, 'admin', {
  namespace: 'mailu',
  resources: { requests: { cpu: '100m' } },
  image: { repository: 'mailu/admin', tag: '2.0' },
});

// Avoid: Many individual parameters
new AdminConstruct(this, 'admin',
  'mailu',
  { cpu: '100m' },
  'mailu/admin',
  '2.0'
);

Benefits:

  • Type-safe with TypeScript interfaces

  • Easy to add optional parameters

  • Clear parameter names

  • IDE autocomplete support

2. Shared Configuration Pattern#

Pattern: Extract common configuration to shared objects

// Shared environment ConfigMap
const sharedConfig = new ConfigMap(this, 'shared-config', {
  metadata: { namespace: config.namespace },
  data: {
    DOMAIN: config.domain,
    HOSTNAMES: config.hostnames.join(','),
  },
});

// Reference in multiple constructs
new AdminConstruct(this, 'admin', {
  ...config,
  sharedConfigMap: sharedConfig,
});

Benefits:

  • Avoid duplication

  • Single source of truth

  • Easier to maintain

  • Consistent across components

3. Conditional Resource Creation#

Pattern: Create resources based on configuration flags

export class MailuChart extends Chart {
  constructor(scope: Construct, id: string, config: MailuConfig) {
    super(scope, id);

    // Always create core components
    new FrontConstruct(this, 'front', config);
    new AdminConstruct(this, 'admin', config);

    // Conditionally create optional components
    if (config.components.clamav) {
      new ClamAVConstruct(this, 'clamav', config);
    }

    if (config.components.webdav) {
      new WebdavConstruct(this, 'webdav', config);
    }
  }
}

Benefits:

  • Flexible deployments

  • Only create what’s needed

  • Clear intent in code

  • Reduces resource usage

4. Resource Requirements Pattern#

Pattern: Provide sensible defaults with override capability

interface ComponentConfig {
  resources?: {
    requests?: { cpu: string; memory: string };
    limits?: { cpu: string; memory: string };
  };
}

// In construct
const resources = config.resources ?? {
  requests: { cpu: '100m', memory: '256Mi' },
  limits: { cpu: '300m', memory: '512Mi' },
};

Benefits:

  • Works out of the box

  • Production-ready defaults

  • Easy to customize

  • Explicit resource management

5. Secret Reference Pattern#

Pattern: Reference existing secrets instead of inline values

interface SecretReference {
  name: string;  // Secret name
  key: string;   // Key within secret
}

// Usage
database: {
  password: {
    name: 'postgres-credentials',
    key: 'password',
  },
}

// In construct
const passwordEnv = EnvValue.fromSecretValue({
  secret: Secret.fromSecretName(this, 'db-secret', config.password.name),
  key: config.password.key,
});

Benefits:

  • Never expose secrets in code

  • Integrate with secret management tools

  • Type-safe secret references

  • Clear intent

Testing Patterns#

1. Snapshot Testing#

Pattern: Verify generated manifests haven’t changed unexpectedly

test('generates expected manifests', () => {
  const app = Testing.app();
  const chart = new MailuChart(app, 'test', { ... });
  const results = Testing.synth(chart);
  expect(results).toMatchSnapshot();
});

Benefits:

  • Catch unintended changes

  • Document expected output

  • Fast regression testing

2. Resource Count Testing#

Pattern: Verify expected number of resources

test('creates correct number of resources', () => {
  const app = Testing.app();
  const chart = new MailuChart(app, 'test', { ... });
  const results = Testing.synth(chart);

  expect(results.filter(r => r.kind === 'Deployment')).toHaveLength(5);
  expect(results.filter(r => r.kind === 'Service')).toHaveLength(5);
});

Benefits:

  • Verify resource creation

  • Catch missing/extra resources

  • Quick validation

3. Configuration Validation Testing#

Pattern: Test that invalid configurations are rejected

test('rejects invalid domain', () => {
  const app = Testing.app();
  expect(() => {
    new MailuChart(app, 'test', {
      domain: 'invalid domain with spaces',
    });
  }).toThrow('Invalid domain format');
});

Benefits:

  • Fail fast on bad config

  • Clear error messages

  • Prevent invalid deployments

Best Practices#

1. Type Everything#

Use TypeScript interfaces for all configuration:

interface MailuConfig {
  domain: string;
  namespace: string;
  components: ComponentToggles;
  // ... all fields typed
}

2. Validate Early#

Validate configuration in construct constructors:

if (!isValidDomain(config.domain)) {
  throw new Error(`Invalid domain: ${config.domain}`);
}

3. Provide Defaults#

Make common configurations work with minimal input:

const resources = config.resources ?? DEFAULT_RESOURCES;
const image = config.image ?? DEFAULT_IMAGE;

4. Document Decisions#

Use JSDoc comments to explain choices:

/**
 * Creates Front component with Nginx reverse proxy.
 *
 * Note: When using Traefik TLS termination, the nginx-patch-configmap
 * is automatically applied to wrap backend connections properly.
 */
export class FrontConstruct extends Construct { ... }

5. Keep Constructs Focused#

Each construct should have a single responsibility:

// Good: Single component
class AdminConstruct { ... }
class FrontConstruct { ... }

// Avoid: Multiple components in one construct
class AllMailuComponents { ... }

See Also#


This is a placeholder explanation. Content will be expanded by the docwriter with:

  • More advanced patterns

  • Anti-patterns to avoid

  • Real-world examples

  • Performance considerations

  • Debugging techniques

  • Migration patterns