Cloud platform case study

Redis/ElastiCache platform caching across serverless apps.

Redis work can sound small if it is described as "added caching." The harder part was the platform boundary: shared ElastiCache infrastructure, service-scoped credentials, keyspace rules, Lambda runtime configuration, and a migration path downstream applications could follow.

Domain
Shared cache platform for serverless apps
AWS shape
ElastiCache Serverless, Lambda, Secrets Manager-style payloads
Boundary
Per-service Redis users, keyspace-scoped access
Validation
Sanitized TypeScript companion with tests
Scope
Local companion, no AWS deploy
Source files Show files in the companion repository

Companion repository

01 — Problem

A shared cache gets risky when every service wires it differently.

Several serverless applications can need Redis for the same broad reason: avoid expensive or repeated reads on hot paths. The risk is not Redis itself. It is drift, the small wiring decisions each service makes alone that only become a problem once several services share one cache.

  • Cache key collisions when services invent their own prefixes.
  • Credential blast radius when one broad Redis user is shared.
  • Drift in secret shapes and connection settings between services.
  • Unclear ownership of who created which Redis user and why.
  • App migrations that depend on tribal knowledge instead of a checklist.
02 — Options

The useful platform layer is small, but it needs a clear boundary.

03 — Architecture

One shared cache foundation, service-level access around it.

The platform stack owns cache infrastructure and the exported endpoint and user-group identifiers. Each application stack owns its Redis user, generated secret, Lambda permission, runtime client, and cache behavior. That keeps reuse in the infrastructure layer without taking application ownership away.

Platform stack
ElastiCacheserverless Redis User groupshared membership Exportsendpoint + group id
A service creates its own scoped user, then connects to the shared cache and consumes the exported endpoint and group id.
Service stack
Redis userservice scoped Generated secrethost / user / keyspace Lambda appREDIS_SECRET_ARN
The point is not to hide Redis. It is to put the repeatable infrastructure and credential shape in one place, then let applications decide what they cache and how they handle misses.
04 — Implementation

The TypeScript surface keeps the moving parts named.

The companion repo is not a full CDK construct library. It is a typed model of the decisions that mattered: shared cache exports, service-scoped access, idempotent user planning, and Lambda runtime configuration.

cache-platform.ts

Shared cache foundation

Models the shared ElastiCache Serverless foundation, subnet validation, and app-facing exports.

export function createCachePlatformPlan(input: CachePlatformPlanInput): CachePlatformPlan {
  const selectedSubnets = [...new Set(input.subnetIds ?? [])].slice(0, 3);
  if (selectedSubnets.length < 2) {
    throw new Error("ElastiCache Serverless needs at least 2 subnets");
  }

  const endpoint = buildCacheEndpoint(input);
  const userGroupId = `${input.stage}-${normalizeService(input.serviceName)}-redis-users`;

  return {
    cache: {
      engine: "redis",
      name: `${input.stage}-${normalizeService(input.serviceName)}`,
      port: 6379,
      subnetIds: selectedSubnets,
      securityGroupIds: input.securityGroupIds ?? [],
      userGroupId
    },
    exports: {
      endpoint,
      userGroupId,
      userGroupArnParameter: `/platform/${input.stage}/${input.region}/redis/default/userGroupArn`,
      endpointParameter: `/platform/${input.stage}/${input.region}/redis/default/endpoint`
    }
  };
}
redis-user-pattern.ts

Per-service Redis user

Builds generated secret payloads, Redis usernames, keyspaces, and scoped access strings.

export function createRedisUserPattern(input: RedisUserPatternInput): RedisUserPattern {
  const secret = createServiceSecret(input);

  return {
    secretName: `/services/${normalizeKeyspace(input.serviceName)}/redis/${secret.keyspace}`,
    secret,
    user: {
      userId: secret.username,
      username: secret.username,
      accessString: buildAccessString(secret.keyspace)
    },
    userGroupMembership: {
      userGroupId: input.userGroupId,
      userIdsToAdd: [secret.username]
    }
  };
}
redis-user-handler.ts

Idempotent user action plan

Plans create, modify, and group-membership actions. Runs locally without an AWS account or a live Redis connection.

export function planRedisUserChange(input: PlanRedisUserChangeInput): RedisUserAction[] {
  const username = normalizeServiceUser(input.serviceName);
  const accessString = buildAccessString(input.keyspace);
  const userExists = (input.existingUsers ?? []).some(
    (user) => user.userId === username || user.username === username
  );
  const isInGroup = (input.currentGroupUserIds ?? []).includes(username);
  const actions: RedisUserAction[] = [];

  actions.push(userExists
    ? { type: "modify-user", userId: username, passwords: [input.password], accessString }
    : { type: "create-user", userId: username, username, engine: "redis", passwords: [input.password], accessString }
  );

  if (!isInGroup) {
    actions.push({ type: "add-user-to-group", userId: username });
  }

  return actions;
}
lambda-redis-client.ts

Lambda connection boundary

Converts the secret payload into a TLS Redis connection shape and key prefix. Validates required fields before the client is built.

export function createRedisConnectionConfig(secret: RedisSecret): RedisConnectionConfig {
  for (const field of ["host", "port", "username", "password"] as const) {
    if (secret[field] === undefined || secret[field] === "") {
      throw new Error(`Redis secret is missing ${field}`);
    }
  }

  return {
    url: buildRedisUrl(secret),
    username: secret.username,
    password: secret.password,
    keyPrefix: secret.keyspace ? `${secret.keyspace}:` : "",
    socket: {
      tls: secret.tls !== false,
      checkServerIdentity: false
    }
  };
}
05 — Migration path

The adoption path matters as much as the cache.

A shared platform pattern only helps if application teams can adopt it without guessing which permissions, secrets, health checks, and cleanup steps are required.

  1. 01

    Create the service Redis user and generated secret.

  2. 02

    Inject REDIS_SECRET_ARN into the consuming Lambda environment.

  3. 03

    Grant the Lambda role read access to only that Redis secret.

  4. 04

    Build the Redis client from host, port, username, password, and keyspace.

  5. 05

    Add a Redis ping or cache-read signal to the service health path.

  6. 06

    Remove old Redis configuration after the deployment is stable.

06 — Tests

The tests cover the provisioning behavior that is easy to drift.

The local tests check the behavior without deploying AWS resources or connecting to Redis. That makes the public sample deterministic while still showing what would matter in a real platform implementation.

Check Command Result
Type check npm run check TypeScript strict mode passes
Behavior tests npm test 11 Node test scenarios pass
Build gate npm run build tsc --noEmit passes
test/redis-user-handler.test.ts

Example behavior test

11 Node test scenarios pass. This one asserts the create-user and add-user-to-group actions fire together for a new service user.

test("plans user creation and group membership for new service user", () => {
  const actions = planRedisUserChange({
    serviceName: "enrollment-api",
    keyspace: "enrollment-api",
    password: "secret",
    existingUsers: [],
    currentGroupUserIds: []
  });

  assert.deepEqual(actions.map((action) => action.type), [
    "create-user",
    "add-user-to-group"
  ]);
});
07 — Tradeoffs

What this design chooses, and what it does not claim.

  1. 01

    Provisioning becomes explicit

    Per-service Redis users add a provisioning step. I prefer that cost here because credential rotation, group membership, and access changes become reviewable instead of manual.

  2. 02

    Keyspace isolation is a boundary, not authorization

    Redis access strings and key prefixes reduce accidental collisions. They do not replace application authorization, so the service still owns what it caches and who can read it.

  3. 03

    A caching boundary, not a datastore of record

    This is low-latency shared cache access. It is close to online serving, but it does not promise freshness guarantees or consistency between a cache and a source of truth. The service still owns what is safe to cache and for how long.

  4. 04

    The public repo is intentionally local

    The companion repository does not deploy AWS resources. It keeps the behavior deterministic so the pattern can be evaluated without account IDs, secrets, or employer implementation details.

What this case does not claim

  • No employer code, private package names, client service names, account IDs, real secrets, or production endpoint values are included.
  • No Redis benchmark or load-test result is claimed from the public companion repository.
  • The companion models the access and provisioning shape, not cache invalidation or freshness scenarios. Those would be a natural next extension.

Toward production

  • Wire the generated secret to real Secrets Manager rotation instead of a static payload.
  • Attach the scoped secret-read grant to the deployed Lambda role, not a modeled permission.
  • Replace the SSM-style exports with real parameter wiring the service stack reads at deploy time.
  • Add the Redis ping to the deployed health path so a cache outage shows up as a service signal.

Related pages.