İçeriğe atla

AWS CDK Proje Yapısı: Service-Based ve Domain-Based Organizasyon

AWS CDK projelerinde service-based, domain-based, feature-based ve layer-based organizasyon patternlerini karar çerçeveleri ve örneklerle ne zaman seçeceğini öğren.

Ayhan Sipahi Ayhan Sipahi

Bir AWS CDK uygulaması aslında bir program; klasör ve stack düzenini framework sana dayatmaz, bu bir tasarım kararıdır. Karar verilmeyince bedeli sonra çıkar: tight coupling, stack’ler arasında circular dependency ve başka bir ekibe sormadan çalıştırılamayan deployment’lar. Ölçek büyüdükçe ayakta kalan düzen, ince bir foundation katmanıyla birlikte domain-based organizasyondur: bir business domain’in sahip olduğu her şey tek klasörde ve tek stack’te durur, VPC, security ve monitoring ise altlarında ortak bir stack’te kalır.

Service-based, feature-based, layer-based ve hybrid düzenlerin de kazandığı bağlamlar var; aşağıdaki her pattern çalışan TypeScript örneğiyle ve bu varsayılanı hangi koşullarda geçtiğiyle birlikte geliyor. Karar çerçeveleri de en çok canını yakan iki soruyu ele alıyor: construct eklemek yerine ne zaman stack bölmeli ve monorepo ne zaman yük olmaya başlar.

Gelişigüzel Düzen Nerede Çöker#

CDK projelerinde tekrar eden bir pattern: projeler iyi niyetlerle başlıyor ama zamanla maintain edilmesi zorlaşıyor. Infrastructure code organik olarak büyüyor, dosyalar o an nereye denk gelirse oraya açılıyor ve kısa sürede ekipler merge conflict’ler, belirsiz ownership ve çözülemeyen deployment dependency’lerle uğraşıyor.

Teknik sorunlar birkaç şekilde ortaya çıkıyor. AWS service’lerine göre organize olan ekiplerde (ayrı lambda/, dynamodb/, api-gateway/ klasörleri) tek bir business özelliğindeki değişiklik birden fazla directory’ye dokunmayı gerektiriyor. Cross-stack referanslar, bir şeyler bozulana kadar belli olmayan deployment dependency’ler yaratıyor. Birden fazla ekip shared infrastructure üzerinde çalıştığında, belirsiz domain sınırları merge conflict’lere ve koordinasyon yüküne yol açıyor.

Asıl soru dosyaların nerede durduğu değil. Infrastructure’ı business mimarisini, deployment gereksinimlerini ve ekip yapısını yansıtacak, sistemleri kırılgan yapan coupling’e de düşmeyecek şekilde nasıl modelleyeceğin.

Hangi düzeni seçersen seç, aynı taleplerin altında ayakta kalması gerekiyor. Ekipler kendi infrastructure değişikliklerini diğer ekiplerle koordine etmeden, ilgisiz sistemleri kırma endişesi olmadan deploy edebilmeli. Her stack ve construct’ın belirgin bir sahibi olmalı ki bir şey bozulduğunda kime soracağın belli olsun. İlişkili kaynaklar bir arada durmalı, developer’lar aradıklarını hızlıca bulabilsin. Düzen, proje 10 kaynaktan 500’e ve 1 ekipten 10 ekibe büyüdüğünde büyük bir yeniden yapılandırma gerektirmeden çalışmalı. VPC’ler, security policy’ler ve monitoring gibi shared infrastructure da tekrarlama ya da garip dependency’ler doğurmadan bir yere oturmalı.

Dört Düzen ve Bir Karma Yaklaşım#

Service-Based Organizasyon#

Service-based pattern, code’u AWS service türüne göre organize eder: tüm Lambda fonksiyonları bir arada, tüm DynamoDB tabloları bir arada, tüm API Gateway’ler bir arada.

// Directory yapısı
// cdk-project/
// ├── lib/
// │  ├── lambda/
// │  │  ├── user-handler.ts
// │  │  ├── order-handler.ts
// │  │  └── payment-handler.ts
// │  ├── dynamodb/
// │  │  ├── user-table.ts
// │  │  ├── order-table.ts
// │  │  └── payment-table.ts
// │  ├── api-gateway/
// │  │  ├── user-api.ts
// │  │  ├── order-api.ts
// │  │  └── payment-api.ts
// │  └── stacks/
// │  ├── lambda-stack.ts
// │  ├── dynamodb-stack.ts
// │  └── api-gateway-stack.ts

// lib/stacks/dynamodb-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Table, BillingMode, AttributeType } from 'aws-cdk-lib/aws-dynamodb';

export class DynamoDBStack extends Stack {
  public readonly userTable: Table;
  public readonly orderTable: Table;
  public readonly paymentTable: Table;

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

    this.userTable = new Table(this, 'UserTable', {
      partitionKey: { name: 'userId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST
    });

    this.orderTable = new Table(this, 'OrderTable', {
      partitionKey: { name: 'orderId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST
    });

    this.paymentTable = new Table(this, 'PaymentTable', {
      partitionKey: { name: 'paymentId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST
    });
  }
}

// lib/stacks/lambda-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Table } from 'aws-cdk-lib/aws-dynamodb';

export interface LambdaStackProps extends StackProps {
  readonly userTable: Table;
  readonly orderTable: Table;
  readonly paymentTable: Table;
}

export class LambdaStack extends Stack {
  constructor(scope: Construct, id: string, props: LambdaStackProps) {
    super(scope, id, props);

    const userHandler = new NodejsFunction(this, 'UserHandler', {
      entry: 'src/handlers/user.ts',
      environment: {
        TABLE_NAME: props.userTable.tableName
      }
    });

    props.userTable.grantReadWriteData(userHandler);

    // Order ve payment handler'lar için benzer...
  }
}

Bu yaklaşım başta mantıklı görünüyor: tüm Lambda fonksiyonları bir yerde, tutarlı konfigürasyonlar uygulamak kolay. Ancak problemler hızla belirgin hale geliyor:

  • User fonksiyonalitesinde değişiklik yapmak birden fazla directory ve stack’e dokunmayı gerektiriyor
  • Cross-stack referanslar deployment dependency’leri yaratıyor (DynamoDB stack, Lambda stack’ten önce deploy edilmeli)
  • Team ownership belirsiz (“Lambda stack”in sahibi kim?)
  • Infrastructure’ın bir alt kümesini (sadece user ile ilgili kaynaklar) deploy etmek zor

Küçük bir projede bunların hiçbiri fazla canını yakmaz. 10’dan az kaynak, öğrenme ya da deney aşaması veya tek service’li bir uygulama; bu boyutta düzen henüz önemli olmaya başlamamıştır.

Domain-Based Organizasyon#

Domain-based pattern, code’u business domain’ine veya bounded context’e göre organize eder, bir domain için tüm infrastructure’ı bir arada gruplar.

// Directory yapısı
// cdk-project/
// ├── lib/
// │  ├── domains/
// │  │  ├── user/
// │  │  │  ├── user-stack.ts
// │  │  │  ├── user-handler.ts
// │  │  │  ├── user-table.ts
// │  │  │  ├── user-api.ts
// │  │  │  └── constructs/
// │  │  │  └── user-service.ts
// │  │  ├── order/
// │  │  │  ├── order-stack.ts
// │  │  │  ├── order-handler.ts
// │  │  │  ├── order-table.ts
// │  │  │  └── order-api.ts
// │  │  └── payment/
// │  │  ├── payment-stack.ts
// │  │  ├── payment-handler.ts
// │  │  └── payment-table.ts
// │  └── shared/
// │  ├── networking-stack.ts
// │  ├── security-stack.ts
// │  └── monitoring-stack.ts

// lib/domains/user/user-stack.ts
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Table, BillingMode, AttributeType, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RestApi, LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';

export class UserStack extends Stack {
  public readonly api: RestApi;
  public readonly apiEndpoint: string;

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

    // User ile ilgili tüm infrastructure bir arada
    const table = new Table(this, 'UserTable', {
      partitionKey: { name: 'userId', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST,
      encryption: TableEncryption.AWS_MANAGED,
      pointInTimeRecovery: true,
      removalPolicy: RemovalPolicy.RETAIN
    });

    const handler = new NodejsFunction(this, 'UserHandler', {
      entry: 'src/handlers/user.ts',
      environment: {
        TABLE_NAME: table.tableName
      }
    });

    table.grantReadWriteData(handler);

    this.api = new RestApi(this, 'UserApi', {
      restApiName: 'user-service',
      deployOptions: {
        stageName: 'v1',
        tracingEnabled: true
      }
    });

    const users = this.api.root.addResource('users');
    users.addMethod('GET', new LambdaIntegration(handler));
    users.addMethod('POST', new LambdaIntegration(handler));

    const user = users.addResource('{userId}');
    user.addMethod('GET', new LambdaIntegration(handler));
    user.addMethod('PUT', new LambdaIntegration(handler));
    user.addMethod('DELETE', new LambdaIntegration(handler));

    this.apiEndpoint = this.api.url;
  }
}

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { UserStack } from '../lib/domains/user/user-stack';
import { OrderStack } from '../lib/domains/order/order-stack';
import { PaymentStack } from '../lib/domains/payment/payment-stack';

const app = new App();

const userStack = new UserStack(app, 'UserStack', {
  env: { account: '123456789012', region: 'us-east-1' }
});

const orderStack = new OrderStack(app, 'OrderStack', {
  env: { account: '123456789012', region: 'us-east-1' },
  userApiEndpoint: userStack.apiEndpoint
});

// Order stack, user stack'e bağımlı
orderStack.addDependency(userStack);

const paymentStack = new PaymentStack(app, 'PaymentStack', {
  env: { account: '123456789012', region: 'us-east-1' }
});

Bu organizasyon, infrastructure’ı business yetenekleriyle hizalıyor. User fonksiyonalitesindeki değişiklikler sadece user/ directory’sine dokunuyor. Team ownership açık: user stack’in sahibi user ekibi. Domain’ler (shared infrastructure’dan sonra) bağımsız deploy edilebiliyor ve gerekirse bir domain’i ayrı bir repository’ye çıkarmak basit. Bu özellikler microservices mimarilerinde ve çok ekipli organizasyonlarda, bounded context’lerin zaten net olduğu her yerde karşılığını veriyor.

Single-Table Design ile uyum: Domain-based organizasyon, DynamoDB Single-Table Design pattern’i ile mükemmel uyumludur. Single table birden fazla entity type’ı (User, Order, Payment) içerdiğinde, her domain kendi access pattern’lerini ve repository logic’ini kendi klasöründe tutabilir. Örneğin user/ klasöründe user-repository.ts dosyası User entity’sine özel query’leri içerir, order/ klasöründe order-repository.ts Order access pattern’lerini yönetir; hepsi de aynı physical table üzerinde çalışır. Tablo definition’ı shared/ klasöründe olur, domain’ler sadece access pattern’lerini own eder.

Feature-Based Organizasyon#

Product odaklı ekipler için, user’a yönelik özelliklere göre organize olmak teknik domain’lerden daha mantıklı.

// Directory yapısı
// cdk-project/
// ├── lib/
// │  ├── features/
// │  │  ├── authentication/
// │  │  │  ├── auth-stack.ts
// │  │  │  ├── cognito-pool.ts
// │  │  │  ├── auth-lambda.ts
// │  │  │  └── auth-api.ts
// │  │  ├── user-profile/
// │  │  │  ├── profile-stack.ts
// │  │  │  ├── profile-handler.ts
// │  │  │  └── profile-table.ts
// │  │  ├── notifications/
// │  │  │  ├── notification-stack.ts
// │  │  │  ├── sns-topics.ts
// │  │  │  ├── email-handler.ts
// │  │  │  └── sms-handler.ts
// │  │  └── search/
// │  │  ├── search-stack.ts
// │  │  ├── opensearch-domain.ts
// │  │  └── indexer-lambda.ts

// lib/features/notifications/notification-stack.ts
import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Topic } from 'aws-cdk-lib/aws-sns';
import { EmailSubscription, SmsSubscription, SqsSubscription, LambdaSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { Queue } from 'aws-cdk-lib/aws-sqs';

export class NotificationStack extends Stack {
  public readonly emailTopic: Topic;
  public readonly smsTopic: Topic;

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

    // Email bildirimleri
    this.emailTopic = new Topic(this, 'EmailTopic', {
      displayName: 'Email Notifications'
    });

    const emailQueue = new Queue(this, 'EmailQueue', {
      visibilityTimeout: Duration.seconds(300)
    });

    const emailHandler = new NodejsFunction(this, 'EmailHandler', {
      entry: 'src/handlers/send-email.ts',
      environment: {
        SMTP_HOST: 'smtp.example.com'
      }
    });

    emailHandler.addEventSource(new SqsEventSource(emailQueue));
    this.emailTopic.addSubscription(new SqsSubscription(emailQueue));

    // SMS bildirimleri
    this.smsTopic = new Topic(this, 'SmsTopic', {
      displayName: 'SMS Notifications'
    });

    const smsHandler = new NodejsFunction(this, 'SmsHandler', {
      entry: 'src/handlers/send-sms.ts'
    });

    this.smsTopic.addSubscription(new LambdaSubscription(smsHandler));
  }
}

Feature klasörleri; özellikler birden fazla AWS service’ine ve domain’e yayıldığında, ekipler teknik katmanlardan ziyade product özellikleri etrafında organize olduğunda ve product, özelliklerin birbirinden bağımsız geliştirilip deploy edilmesini gerektirdiğinde karşılığını veriyor.

Layer-Based Organizasyon#

Layer-based organizasyon, infrastructure’ı teknik katmana göre ayırır, kaynakları lifecycle ve değişim sıklığına göre gruplar.

// Directory yapısı
// cdk-project/
// ├── lib/
// │  ├── layers/
// │  │  ├── foundation/
// │  │  │  ├── vpc-stack.ts
// │  │  │  ├── security-groups-stack.ts
// │  │  │  └── base-stack.ts
// │  │  ├── data/
// │  │  │  ├── rds-stack.ts
// │  │  │  ├── dynamodb-stack.ts
// │  │  │  └── s3-stack.ts
// │  │  ├── compute/
// │  │  │  ├── lambda-stack.ts
// │  │  │  ├── ecs-stack.ts
// │  │  │  └── batch-stack.ts
// │  │  └── api/
// │  │  ├── apigw-stack.ts
// │  │  └── alb-stack.ts

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { FoundationStack } from '../lib/layers/foundation/vpc-stack';
import { DataStack } from '../lib/layers/data/dynamodb-stack';
import { ComputeStack } from '../lib/layers/compute/lambda-stack';
import { ApiStack } from '../lib/layers/api/apigw-stack';

const app = new App();

// Foundation katmanı - bir kez deploy edilir, nadiren güncellenir
const foundationStack = new FoundationStack(app, 'Foundation', {
  env: { account: '123456789012', region: 'us-east-1' }
});

// Data katmanı - stateful, RETAIN removal policy gerektirir
const dataStack = new DataStack(app, 'Data', {
  env: { account: '123456789012', region: 'us-east-1' },
  vpc: foundationStack.vpc
});

// Compute katmanı - stateless, sık güncellenir
const computeStack = new ComputeStack(app, 'Compute', {
  env: { account: '123456789012', region: 'us-east-1' },
  vpc: foundationStack.vpc,
  tables: dataStack.tables
});

// API katmanı - deployment artifact, çok sık güncellemeler
const apiStack = new ApiStack(app, 'Api', {
  env: { account: '123456789012', region: 'us-east-1' },
  handlers: computeStack.handlers
});

// Açık dependency'ler doğru deployment sırasını garantiler
dataStack.addDependency(foundationStack);
computeStack.addDependency(dataStack);
apiStack.addDependency(computeStack);

Katmanlara bölmek stateful ve stateless kaynakları ayrı tutuyor ve her katmanın kendi ritminde ilerlemesine izin veriyor: foundation nadiren, API’ler sürekli değişiyor. Data katmanı nadiren değiştiği için deployment riski azalıyor ve her katman kendi removal policy’sini taşıyabiliyor. Buna karşılık business logic katmanlara dağılıyor, team ownership bulanıklaşıyor ve tam bir özelliği deploy etmek birden fazla katman arasında koordinasyon gerektiriyor.

Hybrid Yaklaşım#

Gerçek dünya projeleri genellikle pattern’leri birleştirmekten fayda görür: cross-cutting concern’ler için katmanlar, business logic için domain’ler.

// Directory yapısı
// cdk-project/
// ├── lib/
// │  ├── foundation/
// │  │  ├── network-stack.ts  // Shared VPC, subnet'ler
// │  │  ├── security-stack.ts  // Security group'lar, IAM role'ler
// │  │  └── monitoring-stack.ts  // CloudWatch, X-Ray
// │  ├── domains/
// │  │  ├── user/
// │  │  │  ├── user-service-stack.ts
// │  │  │  └── constructs/
// │  │  │  ├── user-api.ts
// │  │  │  ├── user-storage.ts
// │  │  │  └── user-lambda.ts
// │  │  ├── order/
// │  │  │  ├── order-service-stack.ts
// │  │  │  └── constructs/
// │  │  └── payment/
// │  │  ├── payment-service-stack.ts
// │  │  └── constructs/
// │  ├── shared/
// │  │  ├── constructs/
// │  │  │  ├── api-endpoint.ts  // Tekrar kullanılabilir L3 construct
// │  │  │  ├── lambda-factory.ts // Factory fonksiyonları
// │  │  │  └── monitored-table.ts
// │  │  └── aspects/
// │  │  ├── tagging-aspect.ts
// │  │  ├── security-aspect.ts
// │  │  └── removal-policy-aspect.ts
// │  └── config/
// │  ├── environment.ts  // Type-safe config
// │  ├── dev.ts
// │  ├── staging.ts
// │  └── prod.ts

// lib/foundation/network-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Vpc, SubnetType, GatewayVpcEndpointAwsService } from 'aws-cdk-lib/aws-ec2';

export class NetworkStack extends Stack {
  public readonly vpc: Vpc;

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

    this.vpc = new Vpc(this, 'Vpc', {
      maxAzs: 3,
      natGateways: 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'public',
          subnetType: SubnetType.PUBLIC
        },
        {
          cidrMask: 24,
          name: 'private',
          subnetType: SubnetType.PRIVATE_WITH_EGRESS
        },
        {
          cidrMask: 28,
          name: 'isolated',
          subnetType: SubnetType.PRIVATE_ISOLATED
        }
      ]
    });

    // VPC endpoint'ler data transfer maliyetlerini azaltır
    this.vpc.addGatewayEndpoint('S3Endpoint', {
      service: GatewayVpcEndpointAwsService.S3
    });

    this.vpc.addGatewayEndpoint('DynamoDBEndpoint', {
      service: GatewayVpcEndpointAwsService.DYNAMODB
    });
  }
}

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { NetworkStack } from '../lib/foundation/network-stack';
import { UserServiceStack } from '../lib/domains/user/user-service-stack';
import { OrderServiceStack } from '../lib/domains/order/order-service-stack';
import { getConfig } from '../lib/config/environment';

const app = new App();
const config = getConfig(process.env.STAGE || 'dev');

// Foundation katmanı - ilk deploy edilir, nadiren değişir
const network = new NetworkStack(app, 'Network', {
  env: config.env
});

// Domain stack'leri - business logic, bağımsız deployment
const userStack = new UserServiceStack(app, 'UserService', {
  env: config.env,
  vpc: network.vpc
});

const orderStack = new OrderServiceStack(app, 'OrderService', {
  env: config.env,
  vpc: network.vpc,
  userApiEndpoint: userStack.apiEndpoint
});

orderStack.addDependency(userStack);

Burada foundation katmanı cross-cutting concern’lerle ilgileniyor, domain stack’leri business logic bütünlüğünü koruyor ve dependency graph’i, foundation kurulduktan sonra domain’lerin bağımsız deploy edilebileceği kadar net kalıyor.

Stack ve Repo Sınırlarını Nerede Çizmeli#

Stack ve Construct Seçimi#

CDK’da en yaygın sorulardan biri: “Ne zaman yeni bir stack oluşturmalıyım, ne zaman sadece construct kullanmalıyım?” Cevap deployment bağımsızlığına dayanıyor.

Birden fazla stack kullan:

  • Farklı ekipler farklı parçaları maintain edecekse
  • Farklı deployment schedule’ları varsa (data layer ayda bir deploy edilir, compute layer günlük)
  • Farklı environment’lar veya account’lar varsa (dev bir account’ta, prod başka bir account’ta)
  • CI/CD için bağımsız deployment kritikse
  • CloudFormation’ın stack başına 500 kaynak limitine yaklaşılıyorsa
  • Açık sınırları olan farklı business domain’leri varsa
  • Stateful kaynaklar stateless kaynaklardan farklı removal policy’lere ihtiyaç duyuyorsa

Construct kullan (stack değil):

  • Sadece aynı ekip içinde code ownership ayırıyorsan
  • Kaynaklar birlikte değişiyor ve birlikte deploy edilmeliyse
  • Bağımsız deployment’a ihtiyaç yoksa
  • Kaynaklar tight coupling ve çok sayıda referansa sahipse
  • Sadece code sürdürülebilirliği için organize ediyorsan

İşte bir karar ağacı örneği:

// Soru 1: Kaynaklar birlikte mi değişiyor?
// EVET → Construct'larla tek stack
// HAYIR → Devam

// Soru 2: Farklı ekipler mi maintain ediyor?
// EVET → Ayrı stack'ler
// HAYIR → Devam

// Soru 3: Farklı deployment schedule'ları mı var?
// EVET → Ayrı stack'ler
// HAYIR → Devam

// Soru 4: 500 kaynağa yaklaşıyor musun?
// EVET → Ayrı stack'ler (veya nested stack'ler)
// HAYIR → Construct'larla tek stack

// Örnek: API, Lambda, DynamoDB ile user service
// Hepsi birlikte değişiyor, aynı ekip, birlikte deploy → Tek stack
export class UserServiceStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Ayrı stack'ler değil, construct'larla organize et
    const storage = new UserStorageConstruct(this, 'Storage');
    const compute = new UserComputeConstruct(this, 'Compute', {
      table: storage.table
    });
    const api = new UserApiConstruct(this, 'Api', {
      handler: compute.handler
    });
  }
}

// Örnek: Foundation (VPC) vs Application (Lambda/API)
// Farklı değişim sıklığı, farklı lifecycle → Ayrı stack'ler
const foundationStack = new FoundationStack(app, 'Foundation');
const appStack = new AppStack(app, 'App', {
  vpc: foundationStack.vpc
});
appStack.addDependency(foundationStack);

Monorepo vs Multi-Repo Stratejisi#

Monorepo versus multi-repo kararı, ekiplerin nasıl işbirliği yaptığını ve infrastructure’ı nasıl deploy ettiğini etkiler.

Monorepo pattern:

// Directory yapısı
// company-infrastructure/  # Tek repository
// ├── packages/
// │  ├── foundation/
// │  │  ├── src/
// │  │  │  └── network-stack.ts
// │  │  ├── package.json
// │  │  └── cdk.json
// │  ├── user-service/
// │  │  ├── src/
// │  │  │  └── user-stack.ts
// │  │  ├── package.json
// │  │  └── cdk.json
// │  ├── order-service/
// │  │  ├── src/
// │  │  │  └── order-stack.ts
// │  │  ├── package.json
// │  │  └── cdk.json
// │  └── shared-constructs/
// │  ├── src/
// │  │  ├── api-endpoint.ts
// │  │  └── lambda-factory.ts
// │  ├── package.json
// │  └── tsconfig.json
// ├── package.json  # Root package.json
// ├── nx.json  # NX workspace config
// └── tsconfig.base.json

// package.json (root)
{
  "name": "company-infrastructure",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "nx run-many --target=build --all",
    "deploy:dev": "nx run-many --target=deploy --all --args='--context stage=dev'",
    "deploy:user": "nx run user-service:deploy"
  },
  "devDependencies": {
    "nx": "^17.0.0",
    "aws-cdk": "^2.100.0"
  }
}

Monorepo tek bir source of truth veriyor: service’ler arasında atomic değişiklikler, npm’e publish etmeden shared construct’lar, boundary’ler arasında daha kolay refactoring, daha basit dependency yönetimi ve tek CI/CD pipeline konfigürasyonu. Bedeli ise daha yavaş clone’lar, service’ler arasında sürekli bir tight coupling riski, hangi service’in gerçekten değiştiğini bulan pipeline mantığı, daha kaba team permission’ları ve daha geniş bir deployment blast radius.

Multi-repo iki tarafı da ters çeviriyor. Her service için ayrı repository ve private npm registry’ye publish edilen shared construct’lar; net service boundary’leri, bağımsız versioning ve deployment, belirgin ownership ve permission’lar, daha küçük checkout’lar ve dil çeşitliliğine alan sağlıyor. Karşılığında cross-service değişiklikler birden fazla PR gerektiriyor, shared construct’ların publish ve versioning’i gerekiyor, dependency version’larını yönetmek zorlaşıyor, consistency’i korumak güçleşiyor ve her service kendi pipeline yükünü taşıyor.

Öneri: 10’dan az kişilik ekipler için monorepo ile başla. Atomic değişikliklerin ve shared construct’ların basitliği, küçük ölçekte dezavantajlardan daha ağır basıyor. Team boyutu veya deployment bağımsızlığı gerektirdiğinde multi-repo’ya geç: genellikle 50+ mühendis civarında veya farklı lifecycle’lara sahip gerçekten bağımsız service’lerin olduğu durumda.

Domain’lerin Altında Ne Kalır#

Networking, security, monitoring ve diğer cross-cutting concern’ler domain boundary’lerine temiz bir şekilde uymaz.

Foundation Stack Pattern#

// lib/foundation/foundation-stack.ts
import { Stack, StackProps, Tags } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Vpc, IVpc, SubnetType, SecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { Dashboard, GraphWidget } from 'aws-cdk-lib/aws-cloudwatch';

export interface FoundationStackProps extends StackProps {
  readonly stage: string;
}

export class FoundationStack extends Stack {
  public readonly vpc: IVpc;
  public readonly monitoring: Dashboard;
  public readonly lambdaSecurityGroup: SecurityGroup;

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

    // Standart subnet'lerle VPC
    this.vpc = new Vpc(this, 'Vpc', {
      maxAzs: 3,
      natGateways: props.stage === 'prod' ? 3 : 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'public',
          subnetType: SubnetType.PUBLIC
        },
        {
          cidrMask: 24,
          name: 'private',
          subnetType: SubnetType.PRIVATE_WITH_EGRESS
        },
        {
          cidrMask: 28,
          name: 'isolated',
          subnetType: SubnetType.PRIVATE_ISOLATED
        }
      ]
    });

    // Merkezi monitoring dashboard
    this.monitoring = new Dashboard(this, 'Monitoring', {
      dashboardName: `${props.stage}-metrics`
    });

    // Lambda fonksiyonları için standart security group
    this.lambdaSecurityGroup = new SecurityGroup(this, 'LambdaSG', {
      vpc: this.vpc,
      description: 'Security group for Lambda functions',
      allowAllOutbound: true
    });

    // Bu stack'teki her şeye tag uygula
    Tags.of(this).add('Environment', props.stage);
    Tags.of(this).add('ManagedBy', 'CDK');
    Tags.of(this).add('CostCenter', 'Engineering');
  }
}

// Domain stack'leri foundation'ı consume eder
export class UserServiceStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: StackProps & { foundation: FoundationStack }
  ) {
    super(scope, id, props);

    const handler = new NodejsFunction(this, 'Handler', {
      vpc: props.foundation.vpc,
      securityGroups: [props.foundation.lambdaSecurityGroup],
      entry: 'src/handlers/user.ts'
    });

    // Lambda metric'lerini merkezi dashboard'a ekle
    props.foundation.monitoring.addWidgets(
      new GraphWidget({
        title: 'User Service Invocations',
        left: [handler.metricInvocations()]
      })
    );
  }
}

Cross-Cutting Policy’ler için CDK Aspect’ler#

CDK Aspect’ler, application’daki tüm kaynaklara otomatik olarak policy uygulamanı sağlar.

// lib/shared/aspects/security-compliance-aspect.ts
import { App, IAspect, Aspects } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
import { CfnBucket } from 'aws-cdk-lib/aws-s3';
import { Table } from 'aws-cdk-lib/aws-dynamodb';
import { NodejsFunction, Function } from 'aws-cdk-lib/aws-lambda-nodejs';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { RemovalPolicy } from 'aws-cdk-lib';

export class SecurityComplianceAspect implements IAspect {
  visit(node: IConstruct): void {
    // Tüm S3 bucket'larında encryption'ı zorla
    if (node instanceof CfnBucket) {
      if (!node.bucketEncryption) {
        node.bucketEncryption = {
          serverSideEncryptionConfiguration: [{
            serverSideEncryptionByDefault: {
              sseAlgorithm: 'AES256'
            }
          }]
        };
      }
    }

    // DynamoDB table'larında encryption ve retention'ı zorla
    if (node instanceof Table) {
      // Açık encryption olmayan table'lar AWS managed alır
      node.applyRemovalPolicy(RemovalPolicy.RETAIN);
    }

    // Tüm Lambda fonksiyonlarının log retention'ına sahip olduğunu garantile
    if (node instanceof NodejsFunction || node instanceof Function) {
      const logGroupName = `/aws/lambda/${node.functionName}`;
      new LogGroup(node, 'LogGroup', {
        logGroupName,
        retention: RetentionDays.ONE_WEEK,
        removalPolicy: RemovalPolicy.DESTROY
      });
    }
  }
}

// Tüm app'e uygula
const app = new App();
Aspects.of(app).add(new SecurityComplianceAspect());

Shared Construct Library#

Best practice’leri kapsayan tekrar kullanılabilir L3 construct’lar oluştur. Bu construct’ları factory pattern’leri ve functional composition ile daha da ileri götürebilirsin; AWS CDK Functional Patterns yazısı bu konuyu ele alıyor.

// lib/shared/constructs/monitored-lambda.ts
import { Construct } from 'constructs';
import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Alarm, ComparisonOperator } from 'aws-cdk-lib/aws-cloudwatch';
import { Topic } from 'aws-cdk-lib/aws-sns';
import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions';
import { Tracing } from 'aws-cdk-lib/aws-lambda';

export interface MonitoredLambdaProps extends NodejsFunctionProps {
  readonly alarmEmail?: string;
  readonly errorThreshold?: number;
}

export class MonitoredLambda extends Construct {
  public readonly function: NodejsFunction;
  public readonly errorAlarm: Alarm;
  public readonly throttleAlarm: Alarm;

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

    this.function = new NodejsFunction(this, 'Function', {
      ...props,
      tracing: Tracing.ACTIVE // Default olarak X-Ray aktif
    });

    // Otomatik error alarm
    this.errorAlarm = new Alarm(this, 'ErrorAlarm', {
      metric: this.function.metricErrors(),
      threshold: props.errorThreshold ?? 5,
      evaluationPeriods: 2,
      comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      alarmDescription: `Errors on ${this.function.functionName}`
    });

    // Otomatik throttle alarm
    this.throttleAlarm = new Alarm(this, 'ThrottleAlarm', {
      metric: this.function.metricThrottles(),
      threshold: 1,
      evaluationPeriods: 1,
      comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      alarmDescription: `Throttles on ${this.function.functionName}`
    });

    // İsteğe bağlı SNS notification
    if (props.alarmEmail) {
      const topic = new Topic(this, 'AlarmTopic');
      topic.addSubscription(new EmailSubscription(props.alarmEmail));
      this.errorAlarm.addAlarmAction(new SnsAction(topic));
      this.throttleAlarm.addAlarmAction(new SnsAction(topic));
    }
  }
}

// Herhangi bir domain stack'te kullanım
const handler = new MonitoredLambda(this, 'Handler', {
  entry: 'src/handlers/user.ts',
  alarmEmail: 'oncall@example.com',
  errorThreshold: 10
});

Environment’lar Synth Sırasında Oluşsun#

Dev, staging ve prod’u birlikte yönetmek bilinçli bir configuration stratejisi istiyor. Tutarlılığı koruyan şey, her environment’ı synthesis sırasında oluşturmak.

// config/environment.ts
export interface EnvironmentConfig {
  readonly stage: 'dev' | 'staging' | 'prod';
  readonly account: string;
  readonly region: string;
  readonly vpcCidr: string;
  readonly lambdaDefaults: {
    readonly timeout: number;
    readonly memorySize: number;
  };
  readonly enableDetailedMonitoring: boolean;
}

// config/dev.ts
export const devConfig: EnvironmentConfig = {
  stage: 'dev',
  account: '111111111111',
  region: 'us-east-1',
  vpcCidr: '10.0.0.0/16',
  lambdaDefaults: {
    timeout: 30,
    memorySize: 512
  },
  enableDetailedMonitoring: false
};

// config/prod.ts
export const prodConfig: EnvironmentConfig = {
  stage: 'prod',
  account: '222222222222',
  region: 'us-east-1',
  vpcCidr: '10.1.0.0/16',
  lambdaDefaults: {
    timeout: 60,
    memorySize: 1024
  },
  enableDetailedMonitoring: true
};

// bin/app.ts
import { App } from 'aws-cdk-lib';
import { devConfig, stagingConfig, prodConfig } from '../config';
import { UserServiceStack } from '../lib/domains/user/user-service-stack';

const app = new App();

// Synthesis sırasında tüm environment'ları oluştur
[devConfig, stagingConfig, prodConfig].forEach(config => {
  new UserServiceStack(app, `UserService-${config.stage}`, {
    env: { account: config.account, region: config.region },
    config
  });
});

// Faydalar:
// - Bir kez synthesize et, birden fazla environment'a deploy et
// - Dev'de çalışan kodun prod'da da çalışacağını garantiler
// - TypeScript geliştirme sırasında TÜM environment'ları validate eder
// - `cdk list` tüm environment'lardaki tüm stack'leri gösterir

Bu yaklaşım production configuration’ın geliştirme sırasında validate edilmesini garantiler. Production config’te bir yazım hatası, production deployment sırasında değil, local’de cdk synth çalıştırıldığında hemen yakalanır.

Bölme, Döngüler ve Logical ID’ler#

Erken Stack Bölme#

Çok fazla küçük stack oluşturmak aşırı cross-stack referanslarına ve deployment karmaşıklığına yol açar.

// Aşırı bölme - tek service için 6 stack (bunu yapma)
const vpcStack = new VpcStack(app, 'Vpc');
const sgStack = new SecurityGroupStack(app, 'SG', { vpc: vpcStack.vpc });
const tableStack = new TableStack(app, 'Table');
const lambdaStack = new LambdaStack(app, 'Lambda', {
  table: tableStack.table,
  sg: sgStack.lambdaSG
});
const apiStack = new ApiStack(app, 'Api', { handler: lambdaStack.handler });
const alarmStack = new AlarmStack(app, 'Alarm', {
  lambda: lambdaStack.handler,
  api: apiStack.api
});

// Her değişiklik doğru sırada 6 stack deploy etmeyi gerektirir
// Cross-stack referanslar kırılgan dependency'ler yaratır

// Daha iyi: Organizasyon için construct'larla tek stack
export class UserServiceStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const network = new NetworkConstruct(this, 'Network');
    const storage = new StorageConstruct(this, 'Storage');
    const compute = new ComputeConstruct(this, 'Compute', {
      table: storage.table,
      vpc: network.vpc
    });
    const api = new ApiConstruct(this, 'Api', { handler: compute.handler });
    const monitoring = new MonitoringConstruct(this, 'Monitoring', {
      lambda: compute.handler,
      api: api.restApi
    });
  }
}

// Bir kez deploy et, tüm kaynaklar birlikte güncellenir

Büyük stack’lerle başla ve sadece net bir sebep olduğunda böl: farklı ekipler, farklı lifecycle’lar veya kaynak limitine yaklaşma.

Circular Dependency’ler#

Stack’ler arasındaki circular dependency’ler kaynak ilişkilerini tasarlarken yaygın bir sorun.

// Circular dependency problemi (bunu yapma)
export class UserStack extends Stack {
  public readonly apiUrl: string;

  constructor(scope: Construct, id: string, props: { orderTable: ITable }) {
    super(scope, id);
    // User service order table'a ihtiyaç duyuyor ← OrderStack'e dependency
    const handler = new NodejsFunction(this, 'Handler', {
      environment: { ORDER_TABLE: props.orderTable.tableName }
    });

    this.apiUrl = api.url;
  }
}

export class OrderStack extends Stack {
  public readonly table: Table;

  constructor(scope: Construct, id: string, props: { userApiUrl: string }) {
    super(scope, id);
    // Order service user API'ye ihtiyaç duyuyor ← UserStack'e dependency
    const handler = new NodejsFunction(this, 'Handler', {
      environment: { USER_API_URL: props.userApiUrl }
    });

    this.table = new Table(/*...*/);
  }
}

// İkisinden birini ilk oluşturamıyoruz!

// Çözüm 1: Shared resource'larla foundation stack
const foundationStack = new FoundationStack(app, 'Foundation');

const userStack = new UserStack(app, 'User', {
  sharedTable: foundationStack.sharedTable
});

const orderStack = new OrderStack(app, 'Order', {
  sharedTable: foundationStack.sharedTable,
  userApiUrl: userStack.apiUrl
});

// Çözüm 2: Event-driven (direkt dependency yok)
const userStack = new UserStack(app, 'User');
const orderStack = new OrderStack(app, 'Order');

// Service'ler EventBridge/SQS üzerinden iletişim kurar, cross-reference yok

Cross-Account/Region Referansları#

Farklı AWS hesapları veya bölgeleri arasında kaynak referansı açık işleme gerektirir.

// Bu çalışmaz
const dataStack = new DataStack(app, 'Data', {
  env: { account: '111111111111', region: 'us-east-1' }
});

const appStack = new AppStack(app, 'App', {
  env: { account: '222222222222', region: 'eu-west-1' },
  table: dataStack.table // HATA: Cross-account/region referansı
});

// Çözüm: SSM Parameter Store'a export et, sonra import et
export class DataStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const table = new Table(this, 'Table', {/*...*/});

    new StringParameter(this, 'TableArnParameter', {
      parameterName: '/app/table-arn',
      stringValue: table.tableArn
    });
  }
}

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

    const tableArn = StringParameter.valueFromLookup(
      this,
      '/app/table-arn'
    );

    const table = Table.fromTableArn(this, 'Table', tableArn);
  }
}

Logical ID’ler ve Refactor Komutu#

Logical ID’leri korumadan refactor yapmak kaynak değiştirme ve veri kaybına yol açar.

// Öncesi: service-based organizasyon
new Table(this, 'UserTable', {/*...*/});
// CloudFormation Logical ID: UserTable

// Sonrası: domain-based organizasyon (UserStack'e taşındı)
new Table(this, 'Table', {/*...*/});
// CloudFormation Logical ID: Table - FARKLI!
// CloudFormation eski tabloyu SİLER ve yeni tabloyu OLUŞTURUR

// Çözüm: CDK refactor komutunu kullan (preview)
// cdk refactor rename-construct --from UserTable --to UserDomainTable

// Veya logical ID'yi manuel olarak koru
new Table(this, 'UserTable', {  // Aynı ID'yi tut
  // ...
});

// Veya overrideLogicalId kullan
const table = new Table(this, 'Table', {/*...*/});
(table.node.defaultChild as CfnTable).overrideLogicalId('UserTable');

Domain-Based Ne Zaman Doğru Seçim#

Domain-based varsayılan; ekipler business yeteneklerine göre bölünmüşse, domain’ler farklı ritimlerde deploy ediliyorsa ve proje tek ekibi aşacaksa korumaya değer. İçinde kalırken de kaynaklar birlikte değişiyorsa yeni stack yerine construct’a uzan.

Birkaç düzine kaynaklı, tek ekipli bir projede domain klasörlerinin getirisi yok; o boyutta service-based gruplama okunabilir kalıyor. Birden fazla domain’e yayılan, kullanıcıya dönük özellikler sevk eden product ekipleri için feature klasörleri daha iyi çalışıyor, çünkü deploy ettikleri birim özelliğin kendisi. Ayrı bir platform ekibi olan organizasyonlarda genellikle layer-based stack’ler gerekiyor; stateful data katmanı kendi removal policy’sini ve değişim ritmini taşıyabilsin diye. Hangi yöne geçersen geç, logical ID’leri de taşımanın bir parçası say ve cdk refactor ya da overrideLogicalId ile koru.

Kaynaklar#

İlgili yazılar