Radixia

Blog

Infrastructure-as-Code to power composable commerce AI modules

In the second part of this multi-part series of articles, we present MACA, our open-source solution for AI-composable commerce.

Infrastructure-as-Code to power composable commerce AI modules

With Radixia Maca, our open-source initiative for AI-driven composable commerce, we're not just rethinking e-commerce—we're re-architecting it. At the heart of this architecture is infrastructure-as-code, which ensures consistency, traceability, and repeatability across deployments. In this chapter, we shift from design principles to execution, implementing the complete serverless infrastructure for our platform using AWS CDK (Cloud Development Kit). This gives us the flexibility to define complex cloud architectures in code, leverage reusable constructs, and scale components independently.

In this section, we showcase how to implement the complete infrastructure for our serverless e-commerce using AWS CDK. This approach allows us to define infrastructure as code, making it easier to manage, version, and deploy.

CDK Project Structure

Our CDK project is organized into modular constructs, each responsible for a specific part of the infrastructure:

  1. Main Stack: Coordinates all constructs and defines dependencies
  2. API Gateway Construct: Manages REST APIs for the frontend
  3. Bedrock Search Construct: Configures semantic search with Bedrock
  4. Data Storage Construct: Manages DynamoDB and other storage services
  5. Frontend Construct: Configures frontend hosting
  6. MCP Server Construct: Implements the Model Context Protocol server
  7. Strapi Service Construct: Configures the Strapi service on Fargate

Let's look at the implementation of each component in detail.

Main Stack

The main stack (ServerlessEcommerceStack) is the entry point of our CDK infrastructure. It coordinates all constructs and defines dependencies between them.

// lib/serverless-ecommerce-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { ApiGatewayConstruct } from './api-gateway-construct';
import { BedrockSearchConstruct } from './bedrock-search-construct';
import { DataStorageConstruct } from './data-storage-construct';
import { FrontendConstruct } from './frontend-construct';
import { McpServerConstruct } from './mcp-server-construct';
import { StrapiServiceConstruct } from './strapi-service-construct';

export class ServerlessEcommerceStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Create the data storage construct
    const dataStorage = new DataStorageConstruct(this, 'DataStorage');

    // Create the MCP server construct
    const mcpServer = new McpServerConstruct(this, 'McpServer', {
      vpc: dataStorage.vpc,
      productsTable: dataStorage.productsTable,
      commerceLayerSecret: dataStorage.commerceLayerSecret,
      strapiSecret: dataStorage.strapiSecret,
    });

    // Create the Bedrock search construct
    const bedrockSearch = new BedrockSearchConstruct(this, 'BedrockSearch', {
      productsTable: dataStorage.productsTable,
      commerceLayerSecret: dataStorage.commerceLayerSecret,
      strapiSecret: dataStorage.strapiSecret,
      mcpServerUrl: mcpServer.serviceUrl,
    });

    // Create the Strapi service construct
    const strapiService = new StrapiServiceConstruct(this, 'StrapiService', {
      vpc: dataStorage.vpc,
      strapiSecret: dataStorage.strapiSecret,
      commerceLayerSecret: dataStorage.commerceLayerSecret,
      syncLambda: dataStorage.strapiClSyncLambda,
    });

    // Create the API Gateway construct
    const apiGateway = new ApiGatewayConstruct(this, 'ApiGateway', {
      semanticSearchLambda: bedrockSearch.semanticSearchLambda,
      orderProcessorLambda: dataStorage.orderProcessorLambda,
      inventoryManagerLambda: dataStorage.inventoryManagerLambda,
    });

    // Create the frontend construct
    const frontend = new FrontendConstruct(this, 'Frontend', {
      apiEndpoint: apiGateway.apiEndpoint,
      strapiEndpoint: strapiService.serviceUrl,
    });

    // Outputs
    new cdk.CfnOutput(this, 'ApiEndpoint', {
      value: apiGateway.apiEndpoint,
      description: 'API Gateway endpoint',
    });

    new cdk.CfnOutput(this, 'FrontendUrl', {
      value: frontend.distributionUrl,
      description: 'Frontend URL',
    });

    new cdk.CfnOutput(this, 'StrapiServiceUrl', {
      value: strapiService.serviceUrl,
      description: 'Strapi service URL',
    });

    new cdk.CfnOutput(this, 'McpServerUrl', {
      value: mcpServer.serviceUrl,
      description: 'MCP server URL',
    });
  }
}

Data Storage Construct

The DataStorageConstruct manages all data storage resources, including DynamoDB, Secrets Manager, and related Lambda functions.

// lib/data-storage-construct.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as path from 'path';

export interface DataStorageConstructProps {
  // Optional properties
}

export class DataStorageConstruct extends Construct {
  public readonly productsTable: dynamodb.Table;
  public readonly ordersTable: dynamodb.Table;
  public readonly inventoryTable: dynamodb.Table;
  public readonly commerceLayerSecret: secretsmanager.Secret;
  public readonly strapiSecret: secretsmanager.Secret;
  public readonly vpc: ec2.Vpc;
  public readonly strapiClSyncLambda: lambda.Function;
  public readonly orderProcessorLambda: lambda.Function;
  public readonly inventoryManagerLambda: lambda.Function;
  public readonly lowStockTopic: sns.Topic;

  constructor(scope: Construct, id: string, props?: DataStorageConstructProps) {
    super(scope, id);

    // Create a VPC for services
    this.vpc = new ec2.Vpc(this, 'VPC', {
      maxAzs: 2,
      natGateways: 1,
    });

    // Create DynamoDB tables
    this.productsTable = new dynamodb.Table(this, 'ProductsTable', {
      partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // Only for development environment
    });

    // Add a global secondary index for category
    this.productsTable.addGlobalSecondaryIndex({
      indexName: 'CategoryIndex',
      partitionKey: { name: 'category', type: dynamodb.AttributeType.STRING },
      projectionType: dynamodb.ProjectionType.ALL,
    });

    this.ordersTable = new dynamodb.Table(this, 'OrdersTable', {
      partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // Only for development environment
    });

    this.inventoryTable = new dynamodb.Table(this, 'InventoryTable', {
      partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // Only for development environment
    });

    // Create secrets for credentials
    this.commerceLayerSecret = new secretsmanager.Secret(this, 'CommerceLayerSecret', {
      secretName: 'commerceLayer/credentials',
      description: 'Commerce Layer credentials',
      generateSecretString: {
        secretStringTemplate: JSON.stringify({
          clientId: 'your-client-id',
          clientSecret: 'your-client-secret',
          endpoint: 'https://yourdomain.commercelayer.io',
        }) ,
        generateStringKey: 'password',
      },
    });

    this.strapiSecret = new secretsmanager.Secret(this, 'StrapiSecret', {
      secretName: 'strapi/credentials',
      description: 'Strapi CMS credentials',
      generateSecretString: {
        secretStringTemplate: JSON.stringify({
          apiKey: 'your-api-key',
          endpoint: 'https://your-strapi-endpoint.com',
        }) ,
        generateStringKey: 'password',
      },
    });

    // Create an SNS topic for low stock notifications
    this.lowStockTopic = new sns.Topic(this, 'LowStockTopic', {
      displayName: 'Low stock notifications',
    });

    // Create Lambda functions
    this.strapiClSyncLambda = new lambda.Function(this, 'StrapiClSyncLambda', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, '../lambda/strapi-cl-sync')),
      timeout: cdk.Duration.seconds(30),
      memorySize: 256,
      environment: {
        COMMERCE_LAYER_SECRET_ARN: this.commerceLayerSecret.secretArn,
        STRAPI_SECRET_ARN: this.strapiSecret.secretArn,
        PRODUCTS_TABLE: this.productsTable.tableName,
      },
    });

    this.orderProcessorLambda = new lambda.Function(this, 'OrderProcessorLambda', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, '../lambda/order-processor')),
      timeout: cdk.Duration.seconds(30),
      memorySize: 256,
      environment: {
        COMMERCE_LAYER_SECRET_ARN: this.commerceLayerSecret.secretArn,
        ORDERS_TABLE: this.ordersTable.tableName,
        FROM_EMAIL: 'noreply@serverlessday.com',
        EMAIL_TEMPLATE_NAME: 'OrderConfirmation',
      },
    });

    this.inventoryManagerLambda = new lambda.Function(this, 'InventoryManagerLambda', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, '../lambda/inventory-manager')),
      timeout: cdk.Duration.seconds(30),
      memorySize: 256,
      environment: {
        COMMERCE_LAYER_SECRET_ARN: this.commerceLayerSecret.secretArn,
        INVENTORY_TABLE: this.inventoryTable.tableName,
        LOW_STOCK_THRESHOLD: '5',
        LOW_STOCK_TOPIC_ARN: this.lowStockTopic.topicArn,
      },
    });

    // Grant necessary permissions
    this.commerceLayerSecret.grantRead(this.strapiClSyncLambda);
    this.strapiSecret.grantRead(this.strapiClSyncLambda);
    this.productsTable.grantReadWriteData(this.strapiClSyncLambda);

    this.commerceLayerSecret.grantRead(this.orderProcessorLambda);
    this.ordersTable.grantReadWriteData(this.orderProcessorLambda);

    this.commerceLayerSecret.grantRead(this.inventoryManagerLambda);
    this.inventoryTable.grantReadWriteData(this.inventoryManagerLambda);
    this.lowStockTopic.grantPublish(this.inventoryManagerLambda);

    // Create an EventBridge rule to run the inventoryManagerLambda daily
    new events.Rule(this, 'DailyInventorySync', {
      schedule: events.Schedule.cron({ minute: '0', hour: '0' }),
      targets: [new targets.LambdaFunction(this.inventoryManagerLambda)],
    });
  }
}

Bedrock Search Construct

The BedrockSearchConstruct configures semantic search using Amazon Bedrock and the Model Context Protocol.

// lib/bedrock-search-construct.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as path from 'path';

export interface BedrockSearchConstructProps {
  productsTable: dynamodb.Table;
  commerceLayerSecret: secretsmanager.Secret;
  strapiSecret: secretsmanager.Secret;
  mcpServerUrl: string;
}

export class BedrockSearchConstruct extends Construct {
  public readonly semanticSearchLambda: lambda.Function;
  public readonly bedrockAgentId: string;

  constructor(scope: Construct, id: string, props: BedrockSearchConstructProps) {
    super(scope, id);

    // Create an IAM role for Bedrock access
    const bedrockRole = new iam.Role(this, 'BedrockRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
      ],
    });

    // Add policies for Bedrock access
    bedrockRole.addToPolicy(
      new iam.PolicyStatement({
        actions: [
          'bedrock:InvokeModel',
          'bedrock:InvokeAgent',
          'bedrock:CreateAgent',
          'bedrock:UpdateAgent',
        ],
        resources: ['*'], // Limit to specific resources in production
      })
    );

    // Create the Lambda function for semantic search
    this.semanticSearchLambda = new lambda.Function(this, 'SemanticSearchLambda', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, '../lambda/semantic-search')),
      timeout: cdk.Duration.seconds(30),
      memorySize: 512,
      environment: {
        COMMERCE_LAYER_SECRET_ARN: props.commerceLayerSecret.secretArn,
        STRAPI_SECRET_ARN: props.strapiSecret.secretArn,
        PRODUCTS_TABLE: props.productsTable.tableName,
        MCP_SERVER_URL: props.mcpServerUrl,
      },
      role: bedrockRole,
    });

    // Grant necessary permissions
    props.commerceLayerSecret.grantRead(this.semanticSearchLambda);
    props.strapiSecret.grantRead(this.semanticSearchLambda);
    props.productsTable.grantReadData(this.semanticSearchLambda);

    // Set the Bedrock agent ID (in a real implementation, this would be created via CDK)
    this.bedrockAgentId = 'agent-id-placeholder';

    // Outputs
    new cdk.CfnOutput(this, 'SemanticSearchLambdaArn', {
      value: this.semanticSearchLambda.functionArn,
      description: 'ARN of the semantic search Lambda function',
    });
  }
}

MCP Server Construct

The McpServerConstruct implements the Model Context Protocol server that acts as a connector between Bedrock Agents and data sources.

// lib/mcp-server-construct.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as path from 'path';

export interface McpServerConstructProps {
  vpc: ec2.Vpc;
  productsTable: dynamodb.Table;
  commerceLayerSecret: secretsmanager.Secret;
  strapiSecret: secretsmanager.Secret;
}

export class McpServerConstruct extends Construct {
  public readonly serviceUrl: string;

  constructor(scope: Construct, id: string, props: McpServerConstructProps) {
    super(scope, id);

    // Create an ECR repository for the Docker image
    const repository = new ecr.Repository(this, 'McpServerRepository', {
      repositoryName: 'mcp-server',
      removalPolicy: cdk.RemovalPolicy.DESTROY, // Only for development environment
    });

    // Create an ECS cluster
    const cluster = new ecs.Cluster(this, 'McpServerCluster', {
      vpc: props.vpc,
    });

    // Create an execution role for the ECS task
    const executionRole = new iam.Role(this, 'McpServerExecutionRole', {
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'),
      ],
    });

    // Create a task role for the ECS task
    const taskRole = new iam.Role(this, 'McpServerTaskRole', {
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
    });

    // Grant necessary permissions
    props.commerceLayerSecret.grantRead(taskRole);
    props.strapiSecret.grantRead(taskRole);
    props.productsTable.grantReadData(taskRole);

    // Create a Fargate service
    const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'McpServerService', {
      cluster,
      memoryLimitMiB: 1024,
      cpu: 512,
      desiredCount: 2,
      taskImageOptions: {
        image: ecs.ContainerImage.fromAsset(path.join(__dirname, '../docker/mcp-server')),
        containerPort: 3000,
        environment: {
          COMMERCE_LAYER_SECRET_ARN: props.commerceLayerSecret.secretArn,
          STRAPI_SECRET_ARN: props.strapiSecret.secretArn,
          PRODUCTS_TABLE: props.productsTable.tableName,
          NODE_ENV: 'production',
        },
        taskRole,
        executionRole,
      },
      publicLoadBalancer: true,
    });

    // Configure health check
    fargateService.targetGroup.configureHealthCheck({
      path: '/health',
      interval: cdk.Duration.seconds(60),
      timeout: cdk.Duration.seconds(5),
      healthyThresholdCount: 2,
      unhealthyThresholdCount: 5,
    });

    // Configure auto scaling
    const scaling = fargateService.service.autoScaleTaskCount({
      minCapacity: 2,
      maxCapacity: 10,
    });

    scaling.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 70,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60),
    });

    // Set the service URL
    this.serviceUrl = `http://${fargateService.loadBalancer.loadBalancerDnsName}`;

    // Outputs
    new cdk.CfnOutput(this, 'McpServerServiceUrl', {
      value: this.serviceUrl,
      description: 'MCP Server service URL',
    }) ;
  }
}

You're halfway in — the full article lives on our blog.

Continue reading on blog.radixia.ai →

Free to read. Subscribe there to get new posts by email.