Design for scalability, testability, and flexibility from infrastructure to application

Author : Scott Lewis

Tags : aws, cloud, architecture, serverless, lambda, s3, sns, sqs, ec2, lightsail, docker, full-stack development, nodejs, golang

The principles that guided, and are guiding, the architectural decisions for the vectoricons.net rebuild include: scalability, testability, observability, extensibility, and flexibility. These concrete design constraints are applied at every layer—from AWS infrastructure to application code. Here's how the service layer is designed to meet those principles.

The AWS infrastructure layer uses CDK stacks (network, database, compute, serverless - 12 stacks in total) that deploy independently each with its own local configuration and a shared global configuration. The application layer uses service-oriented architecture with composable mixins and event-driven plugins. Each layer can evolve, scale, and test in isolation.

This post focuses on the service layer patterns that make this possible. I've published the code and comprehensive documentation to demonstrate these patterns in practice.

Repository: github.com/iconifyit/vectoricons-base-public
Documentation: Full architectural docs

Infrastructure: Layered Independence from the Ground Up

The 12 CDK stacks follow a layered, dependency-aware architecture where foundational layers define primitives that upper layers build upon. The VPC and IAM stacks establish networking and security boundaries. Data and storage layers (RDS, S3, Lambda Layers) provide persistence and shared dependencies. Compute layers (API Server, Uploads, Messenger) run application logic and event processing. Application delivery layers (ALB, CloudFront, Amplify, Route53) handle routing, CDN, and DNS. Each stack is self-contained with its own constants file, enabling safe deployment and testing in isolation.

This layered approach embodies the same principles as the service layer: stacks scale independently (add S3 buckets without touching compute), test independently (CDK assertions per stack), and maintain flexibility through explicit resource imports. No stack owns another's resources. No destructive defaults. No wildcard permissions. The result is infrastructure that deploys deterministically, debugs easily, and tears down safely—all while maintaining strong boundaries and minimal coupling.

CDK stacks organized into four architectural layers: Foundation (VPC, IAM), 
  Data & Storage (RDS, S3, Lambda Layers), Compute (API Server, Uploads, 
  Messenger), and Application Delivery (ALB, CloudFront, Amplify, Route53). 
  Arrows show dependency flow from foundation 
  upward.

The 12 CDK stacks grouped into four architectural layers. Dependencies flow upward—Foundation layer primitives enable Data & Storage resources, which support Compute services, culminating in Application Delivery components that handle routing, CDN, and DNS.

Design Principle: Independence at Every Layer

Business logic doesn't know about HTTP requests, database drivers, or user interfaces. Services solve business problems. Repositories handle data access. Entities validate data. Each layer has one job and does it without coupling to adjacent layers.

This independence produces code that works in HTTP APIs, CLI tools, background workers, and test suites without modification. Change the database? Update repositories. Swap HTTP frameworks? Update route handlers. Core logic stays untouched. And the event-driven, plugin feature allows new functionality to be added in response to system events without modifying core code.

The Entity-Repository-Service Pattern

The foundation is a three-layer separation:

Entities represent domain objects with validation logic. They're immutable value objects that ensure data integrity:

class IconEntity extends BaseEntity {
  static get allowedColumns() {
    return ['id', 'name', 'price', 'setId', 'styleId', 'userId'];
  }

  static get validationRules() {
    return {
      name: { required: true, maxLength: 255 },
      price: { type: 'number', min: 0 }
    };
  }
}

Entities don't know about databases or HTTP. They validate data and ensure consistency.

Repositories handle data access. They translate between the domain (entities) and storage (database):

class IconRepository extends BaseRepository {
  static get tableName() { return 'icons'; }
  static get EntityClass() { return IconEntity; }

  async findBySetId(setId, options = {}) {
    return this.findMany({ setId }, options);
  }
}

Repositories abstract database operations. Swap PostgreSQL for MongoDB or an in-memory store, and services don't change.

Services orchestrate business logic. They use repositories to read and write data, apply business rules, and emit events:

class IconService extends BaseService {
  async createIcon(data, user) {
    // Validate user permissions
    await this.accessControl.requireRole(user, 'contributor');

    // Create entity (validates data)
    const icon = IconEntity.create(data);

    // Persist via repository
    const saved = await this.repository.create(icon);

    // Emit event for plugins
    await this.eventBus.emit('icon.created', { icon: saved, user });

    return saved;
  }
}

Services contain no HTTP logic, no SQL queries, no file system operations. They solve business problems using repositories and entities.

Entity-Repository-Service Enables Testability and Flexibility

Testability: Services test without databases or HTTP servers. Swap the real repository for an in-memory implementation:

test('creates icon with valid data', async () => {
  const service = new IconService({
    repository: new InMemoryRepository()
  });

  const icon = await service.createIcon({ name: 'Arrow' }, mockUser);
  expect(icon.name).toBe('Arrow');
});

Flexibility: The same service powers HTTP APIs, CLI commands, and background jobs without modification:

// HTTP endpoint
app.post('/api/icons', async (req, res) => {
  const icon = await iconService.createIcon(req.body, req.user);
  res.json(icon);
});

// CLI command
program.command('create-icon <name>')
  .action(async (name) => {
    const icon = await iconService.createIcon({ name }, currentUser);
    console.log('Created:', icon);
  });

// Background worker
queue.process('create-icon', async (job) => {
  return iconService.createIcon(job.data, job.user);
});

Change how icons are created? Update one service method. Every consumer gets the update. Business logic stays constant while infrastructure evolves—swap PostgreSQL for MongoDB, HTTP for gRPC. Services don't care. The contracts remain stable.

Composition via Mixins

Rather than building feature hierarchies through inheritance, we compose capabilities through mixins. Services mix in only the features they need.

The Problem with Inheritance:

// Fragile inheritance chains
class BaseService {}
class CacheableService extends BaseService {}
class PaginatableService extends CacheableService {}
class AccessControlService extends PaginatableService {}

// What if you need caching but not pagination?
// What if the hierarchy changes?

The Mixin Solution:

const { withActivatable, withPluggableCacheableAndSoftDeletable } = require('./mixins/service');

// Service with multiple composed mixins
class IconService extends withActivatable(withPluggableCacheableAndSoftDeletable(BaseService)) {
  constructor({ repository = new IconRepository({ DB }), entityClass = IconEntity } = {}) {
    super({ repository, entityClass });
  }

  // Service-specific business logic only
  async getIconByUniqueId(uniqueId, options = {}) {
    return this.repository.findByUniqueId(uniqueId, options);
  }

  async getIconsBySetId(setId, options = {}) {
    return this.repository.findBySetId(setId, options);
  }

  async getAllActiveIcons(options = {}) {
    return this.repository.findAllActive(options);
  }

  // Inherits automatically:
  // - activate/deactivate (from withActivatable)
  // - getById() with caching (from CacheableService)
  // - softDelete() (from SoftDeletableService)
  // - event emission with plugins (from PluggableService)
  // - all base CRUD operations (from BaseService)
}

Each mixin adds orthogonal functionality without coupling. IconService gets activation/deactivation, caching, soft delete, and event-driven plugins without writing any of that code. Services compose only what they need.

Example: Access Control Mixin

function withAccessControl(BaseServiceClass) {
  return class AccessControllableService extends BaseServiceClass {
    constructor(options = {}) {
      super(options);
      this.accessControl = options.accessControl || new AccessControlService();
    }

    async ensureAllowed(authz) {
      const allowed = await this.accessControl.enforce(authz);
      if (!allowed) {
        const error = new Error('Forbidden');
        error.statusCode = 403;
        throw error;
      }
    }

    async getOneAuthorized({ where = {}, authz, buildResource, trx } = {}) {
      const record = await this.getOne(where, { trx });
      const resourceFacts = typeof buildResource === 'function'
        ? buildResource(record)
        : null;
      await this.ensureAllowed({ ...authz, resource: resourceFacts });
      return record;
    }
  };
}

Mix this into any service that needs authorization, and access control just works. No duplication, no inheritance chains.

Example: Cache Mixin

const CacheableService = (Base) => class Cacheable extends Base {
  constructor(args = {}) {
    super(args);
    const { cache = {} } = args;

    this.cache = {
      enabled: cache.enabled !== false,
      ttl: cache.ttl || 60,
      adapter: cache.adapter || new InMemoryCacheAdapter(),
    };
  }

  async getById(id, opts = {}) {
    if (!this.cache?.enabled) {
      return super.getById(id, opts);
    }

    const key = `${this.cache.prefix}:id:${id}`;

    // Check cache first
    const cached = await this._cacheGet(key);
    if (cached) return cached;

    // Cache miss - fetch and store
    const result = await super.getById(id, opts);
    if (result) {
      await this._cacheSet(key, result);
    }

    return result;
  }

  // Invalidate on writes
  async create(data, opts = {}) {
    const result = await super.create(data, opts);
    await this._invalidateAll();
    return result;
  }
};

This mixin wraps read operations with caching and invalidates on writes. Services that need caching mix it in. Services that don't, don't pay the cost.

Example: Cursor Pagination Mixin

const withCursorPagination = (BaseClass) => {
  return class extends BaseClass {
    async cursorPaginate({
      filters = {},
      cursor = null,
      limit = 20,
      sortBy = 'createdAt',
      sortOrder = 'desc'
    }) {
      // Decode cursor
      let cursorData = cursor ? CursorEncoder.decode(cursor) : null;

      // Build query with filters
      let query = this.model.query();
      query = this._applyFilters(query, filters);

      // Apply keyset condition for efficient pagination
      if (cursorData) {
        const operator = sortOrder === 'asc' ? '>' : '<';
        query.whereRaw(
          `(${sortBy}, id) ${operator} (?, ?)`,
          [cursorData[sortBy], cursorData.id]
        );
      }

      // Sort and limit
      query.orderBy(sortBy, sortOrder);
      query.orderBy('id', sortOrder);
      query.limit(limit + 1);

      // Execute and build result
      const results = await query;
      const hasNextPage = results.length > limit;
      const items = hasNextPage ? results.slice(0, limit) : results;

      return {
        results: items,
        pageInfo: {
          hasNextPage,
          endCursor: items.length > 0
            ? CursorEncoder.encode(items[items.length - 1])
            : null
        }
      };
    }
  };
};

This mixin adds O(log n) cursor pagination to any repository. No offset/limit performance issues, consistent results even when data changes.

Mixins Enable Flexibility, Testability, and Extensibility

Scalability: Services compose only the capabilities they need. Icon service gets caching and pagination. Transaction service gets access control and audit logging. No bloated base classes carrying unused features.

Testability: Each mixin tests in isolation. Compose tested mixins with confidence. No inheritance chains to mock.

Flexibility: Add or remove features without inheritance hierarchies. Need caching? Mix it in. Don't need it? Don't pay the cost.

Extensibility: New capabilities become new mixins. Write once, compose anywhere. The mixin pattern naturally encourages reusable, composable code.

Event-Driven Plugin Architecture

Services emit events at key moments. Plugins subscribe to events and extend functionality without modifying core code.

Observability Built-In:

Every service inherits from BaseService, which includes the event bus via the ObservableService mixin. This means observability isn't opt-in—it's automatic. All services can emit domain events without additional setup.

// BaseService (simplified)
const { withObservable } = require('./mixins/service');

class RawBaseService {
  constructor({ repository, entityClass }) {
    this.repository = repository;
    this.entityClass = entityClass;
  }
}

// Event emission is mixed in automatically
const BaseService = withObservable(RawBaseService);

Core Pattern:

Services emit events after state-changing operations:

class IconService extends BaseService {
  async createIcon(data, user) {
    const icon = await this.repository.create(IconEntity.create(data));

    // Event bus available automatically from BaseService
    await this.eventBus.emit('icon.created', { icon, user });

    return icon;
  }
}

Plugin Example:

// plugins/notify-slack-on-icon-upload.js
module.exports = (eventBus, { slackService }) => {
  eventBus.on('icon.created', async ({ icon, user }) => {
    await slackService.notify({
      channel: '#uploads',
      message: `${user.username} uploaded "${icon.name}"`
    });
  });
};

Plugins are independent modules. Add notifications, analytics, webhooks, or audit logs without touching service code.

Plugin Orchestration:

// Plugins are loaded at startup
const plugins = [
  require('./plugins/notify-slack-on-icon-upload'),
  require('./plugins/index-in-elasticsearch'),
  require('./plugins/send-upload-confirmation-email')
];

plugins.forEach(plugin => plugin(eventBus, {
  slackService,
  elasticsearchService,
  emailService
}));

Each plugin is self-contained. Enable or disable features by loading or removing plugins.

Events Enable Observability and Extensibility

Scalability: Plugins run independently. Move Slack notifications to a separate service. Move email delivery to a message queue. Core logic doesn't change.

Testability: Test services without plugins. Test plugins without services. Integration tests verify the system works as a whole, but unit tests run fast and isolated.

Flexibility: Add features without touching core code. New business requirement? Write a plugin. Remove a feature? Delete the plugin file. Services stay stable.

Observability: Events create natural audit trails. Every business action emits an event that can be logged, monitored, or analyzed. No instrumentation code clutters business logic.

Extensibility: Plugins extend functionality without modifying core services. Need webhooks? Write a webhook plugin. Need analytics? Write an analytics plugin. Core remains untouched.

From Infrastructure to Application

The service layer sits between infrastructure and delivery mechanisms. Below it: CDK stacks defining network topology, database clusters, compute instances. Above it: HTTP handlers, CLI commands, background workers.

Each layer respects these same architectural principles:

Scalability: Infrastructure scales horizontally (add compute nodes). Services scale vertically (add features through mixins). Both scale independently.

Testability: Infrastructure tests with CDK assertions. Services test with in-memory repositories. Delivery mechanisms test with HTTP mocks. Each layer tests in isolation.

Flexibility: Swap databases without changing services. Swap HTTP frameworks without changing handlers. Swap cloud providers without rewriting application logic.

Observability: Infrastructure emits CloudWatch metrics. Services emit domain events. Delivery mechanisms emit request logs. Each layer provides visibility into its operations.

Extensibility: Infrastructure adds new stacks. Services add new modules. Delivery mechanisms add new endpoints. Each layer extends without disrupting existing functionality.

These aren't theoretical patterns. They power a production e-commerce platform handling real traffic, real transactions, and real money. The repository includes complete implementations with tests and documentation.

Explore the code: github.com/iconifyit/vectoricons-base-public

Read the docs: iconifyit.github.io/vectoricons-base-public


About the Author: Scott Lewis is a Senior Software Engineer specializing in scalable application architecture. He's currently rebuilding vectoricons.net from the ground up with a focus on maintainability, testability, and extensibility. Connect on LinkedIn or visit sketchandbuild.com.

Posted in AWS Cloud | Tag: aws, cloud, architecture, serverless, lambda, s3, sns, sqs, ec2, lightsail, docker, full-stack development, nodejs, golang

Pay it forward

If you find value in the work on this blog, please consider paying it forward and donating to one of the followign charities that are close to my heart.