Claude Skill

software-architecture

System design patterns, Clean Architecture, SOLID principles, domain modeling. Use when making architectural decisions, designing new modules, refactoring a tangled codebase, or reviewing system design.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download sabahattink-antigravity-fullstack-hq-skills_software-architecture-1acbfa7.zip · 3 KB
Part of sabahattink/antigravity-fullstack-hq — 27 skills

Install

skills CLI npx skills add https://github.com/sabahattink/antigravity-fullstack-hq/tree/main/skills/software-architecture
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install sabahattink-antigravity-fullstack-hq@llmmart
Git git clone https://github.com/sabahattink/antigravity-fullstack-hq.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole sabahattink/antigravity-fullstack-hq collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Software Architecture

Clean Architecture Layers

┌─────────────────────────────────────────────┐
│              Frameworks & Drivers           │  ← NestJS, TypeORM, Express
├─────────────────────────────────────────────┤
│           Interface Adapters                │  ← Controllers, Repositories, Presenters
├─────────────────────────────────────────────┤
│            Application Layer                │  ← Use Cases, Application Services
├─────────────────────────────────────────────┤
│              Domain Layer                   │  ← Entities, Value Objects, Domain Services
└─────────────────────────────────────────────┘

Dependency Rule: outer layers depend on inner layers — NEVER the reverse.

Domain Layer

Entities

// domain/user/user.entity.ts
// Entities contain identity and business rules. No framework dependencies.

export class User {
  private constructor(
    public readonly id: UserId,
    public readonly email: Email,
    private _name: string,
    private _role: UserRole,
    public readonly createdAt: Date,
  ) {}

  static create(props: {
    id: UserId
    email: Email
    name: string
    role?: UserRole
  }): User {
    if (!props.name.trim()) {
      throw new DomainError('Name cannot be empty')
    }
    return new User(
      props.id,
      props.email,
      props.name.trim(),
      props.role ?? UserRole.USER,
      new Date(),
    )
  }

  get name(): string { return this._name }
  get role(): UserRole { return this._role }

  rename(newName: string): User {
    if (!newName.trim()) throw new DomainError('Name cannot be empty')
    // Return new instance — immutability
    return new User(this.id, this.email, newName.trim(), this._role, this.createdAt)
  }

  promote(to: UserRole, by: User): User {
    if (by.role !== UserRole.ADMIN) {
      throw new DomainError('Only admins can promote users')
    }
    return new User(this.id, this.email, this._name, to, this.createdAt)
  }
}

Value Objects

// domain/shared/value-objects/email.vo.ts
export class Email {
  private constructor(public readonly value: string) {}

  static create(raw: string): Email {
    const normalized = raw.toLowerCase().trim()
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
      throw new DomainError(`Invalid email: ${raw}`)
    }
    return new Email(normalized)
  }

  equals(other: Email): boolean {
    return this.value === other.value
  }

  toString(): string {
    return this.value
  }
}

// domain/shared/value-objects/money.vo.ts
export class Money {
  private constructor(
    public readonly amount: number,  // in cents
    public readonly currency: string,
  ) {}

  static of(amount: number, currency: string): Money {
    if (amount < 0) throw new DomainError('Amount cannot be negative')
    if (!['USD', 'EUR', 'GBP', 'TRY'].includes(currency)) {
      throw new DomainError(`Unsupported currency: ${currency}`)
    }
    return new Money(Math.round(amount), currency)
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new DomainError('Cannot add different currencies')
    }
    return Money.of(this.amount + other.amount, this.currency)
  }

  format(): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: this.currency,
    }).format(this.amount / 100)
  }
}

Domain Events

// domain/shared/domain-event.ts
export abstract class DomainEvent {
  public readonly occurredAt: Date
  public readonly eventId: string

  constructor(
    public readonly aggregateId: string,
    public readonly eventType: string,
  ) {
    this.occurredAt = new Date()
    this.eventId    = crypto.randomUUID()
  }
}

// domain/user/events/user-created.event.ts
export class UserCreatedEvent extends DomainEvent {
  constructor(
    public readonly userId: string,
    public readonly email: string,
    public readonly name: string,
  ) {
    super(userId, 'user.created')
  }
}

Application Layer

Use Cases (Command/Query pattern)

// application/users/commands/create-user/create-user.command.ts
export class CreateUserCommand {
  constructor(
    public readonly email: string,
    public readonly password: string,
    public readonly name: string,
    public readonly actorId: string,
  ) {}
}

// application/users/commands/create-user/create-user.handler.ts
import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs'

@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  constructor(
    private readonly userRepo:     IUserRepository,
    private readonly hasher:       IPasswordHasher,
    private readonly eventBus:     EventBus,
  ) {}

  async execute(cmd: CreateUserCommand): Promise<UserId> {
    const email = Email.create(cmd.email)

    const exists = await this.userRepo.existsByEmail(email)
    if (exists) throw new ConflictException('Email already in use')

    const passwordHash = await this.hasher.hash(cmd.password)
    const userId       = UserId.generate()

    const user = User.create({
      id:    userId,
      email,
      name:  cmd.name,
      passwordHash,
    })

    await this.userRepo.save(user)
    this.eventBus.publish(new UserCreatedEvent(userId.value, email.value, cmd.name))

    return userId
  }
}

Repository Pattern (Interface in Domain)

// domain/user/user.repository.interface.ts
export interface IUserRepository {
  findById(id: UserId): Promise<User | null>
  findByEmail(email: Email): Promise<User | null>
  existsByEmail(email: Email): Promise<boolean>
  save(user: User): Promise<void>
  delete(id: UserId): Promise<void>
}

// infrastructure/persistence/typeorm/user.typeorm-repository.ts
@Injectable()
export class UserTypeOrmRepository implements IUserRepository {
  constructor(
    @InjectRepository(UserOrmEntity)
    private readonly ormRepo: Repository<UserOrmEntity>,
    private readonly mapper:  UserMapper,
  ) {}

  async findById(id: UserId): Promise<User | null> {
    const row = await this.ormRepo.findOne({ where: { id: id.value } })
    return row ? this.mapper.toDomain(row) : null
  }

  async save(user: User): Promise<void> {
    const row = this.mapper.toOrm(user)
    await this.ormRepo.save(row)
  }
  // ...
}

SOLID in Practice

Single Responsibility

// Bad: UserService does too much
class UserService {
  async register(dto) { /* creates user + sends email + logs audit */ }
  async updateProfile(dto) { /* validates + updates + notifies */ }
  async generateReport() { /* queries DB + formats CSV + sends email */ }
}

// Good: each class has one reason to change
class UserRegistrationService { /* only: validate, create, emit event */ }
class EmailNotificationService { /* only: send emails */ }
class AuditLogService { /* only: write audit entries */ }
class UserReportService { /* only: query, format, export */ }

Open/Closed

// Open for extension, closed for modification
interface NotificationChannel {
  send(message: NotificationMessage): Promise<void>
}

class EmailChannel implements NotificationChannel { /* ... */ }
class SmsChannel   implements NotificationChannel { /* ... */ }
class SlackChannel implements NotificationChannel { /* ... */ }

class NotificationService {
  constructor(private channels: NotificationChannel[]) {}

  // No modification needed when adding a new channel
  async notify(message: NotificationMessage) {
    await Promise.all(this.channels.map(c => c.send(message)))
  }
}

Dependency Inversion

// Domain doesn't depend on infrastructure
// Bad:
class OrderService {
  private repo = new TypeOrmOrderRepository() // concrete dep!
}

// Good:
@Injectable()
class OrderService {
  constructor(
    @Inject(ORDER_REPOSITORY_TOKEN)
    private readonly repo: IOrderRepository, // interface dep
  ) {}
}

Module Design

// users/users.module.ts
@Module({
  imports: [
    TypeOrmModule.forFeature([UserOrmEntity]),
    CqrsModule,
    ConfigModule,
  ],
  controllers: [UsersController],
  providers: [
    // Application
    CreateUserHandler,
    GetUserQueryHandler,
    // Domain services
    UserDomainService,
    // Infrastructure adapters
    {
      provide:  IUserRepository,   // injection token
      useClass: UserTypeOrmRepository,
    },
    {
      provide:  IPasswordHasher,
      useClass: BcryptPasswordHasher,
    },
  ],
  exports: [IUserRepository],  // only export what other modules need
})
export class UsersModule {}

Event-Driven Architecture

// Sagas coordinate cross-module workflows
@Injectable()
export class UserOnboardingSaga {
  @Saga()
  userCreated = (events$: Observable<unknown>): Observable<ICommand> => {
    return events$.pipe(
      ofType(UserCreatedEvent),
      map(event => new SendWelcomeEmailCommand(event.email, event.name)),
    )
  }
}

ADR (Architecture Decision Record) Template

# ADR-001: Use CQRS Pattern for Write-Heavy Modules

## Status
Accepted

## Context
Orders and inventory modules have complex write operations with multiple side effects.
Read and write models diverge significantly.

## Decision
Adopt CQRS using @nestjs/cqrs for these modules.
Simple CRUD modules (users, settings) remain using direct service calls.

## Consequences
+ Clear separation of read/write models
+ Easier to add event sourcing later
+ Better testability via command/query handlers
- Higher initial complexity
- Two data models to maintain in some cases

Forbidden Patterns

  • Never have circular dependencies between modules — restructure into shared modules
  • Never access the database from the domain layer — only through repository interfaces
  • Never put I/O (HTTP, DB, file system) in domain entities or value objects
  • Never use static mutable state in services — it breaks testability and concurrency
  • Never expose ORM entities directly to the API layer — map to DTOs
  • Never put business rules in controllers — they belong in the domain or application layer
  • Never use inheritance where composition would work — prefer interfaces and DI
Files (antigravity-fullstack-hq)
  • SKILL.md 10.7 KB
    ---
    name: software-architecture
    description: System design patterns, Clean Architecture, SOLID principles, domain modeling. Use when making architectural decisions, designing new modules, refactoring a tangled codebase, or reviewing system design.
    ---
    
    # Software Architecture
    
    ## Clean Architecture Layers
    
    ```
    ┌─────────────────────────────────────────────┐
    │              Frameworks & Drivers           │  ← NestJS, TypeORM, Express
    ├─────────────────────────────────────────────┤
    │           Interface Adapters                │  ← Controllers, Repositories, Presenters
    ├─────────────────────────────────────────────┤
    │            Application Layer                │  ← Use Cases, Application Services
    ├─────────────────────────────────────────────┤
    │              Domain Layer                   │  ← Entities, Value Objects, Domain Services
    └─────────────────────────────────────────────┘
    
    Dependency Rule: outer layers depend on inner layers — NEVER the reverse.
    ```
    
    ## Domain Layer
    
    ### Entities
    ```typescript
    // domain/user/user.entity.ts
    // Entities contain identity and business rules. No framework dependencies.
    
    export class User {
      private constructor(
        public readonly id: UserId,
        public readonly email: Email,
        private _name: string,
        private _role: UserRole,
        public readonly createdAt: Date,
      ) {}
    
      static create(props: {
        id: UserId
        email: Email
        name: string
        role?: UserRole
      }): User {
        if (!props.name.trim()) {
          throw new DomainError('Name cannot be empty')
        }
        return new User(
          props.id,
          props.email,
          props.name.trim(),
          props.role ?? UserRole.USER,
          new Date(),
        )
      }
    
      get name(): string { return this._name }
      get role(): UserRole { return this._role }
    
      rename(newName: string): User {
        if (!newName.trim()) throw new DomainError('Name cannot be empty')
        // Return new instance — immutability
        return new User(this.id, this.email, newName.trim(), this._role, this.createdAt)
      }
    
      promote(to: UserRole, by: User): User {
        if (by.role !== UserRole.ADMIN) {
          throw new DomainError('Only admins can promote users')
        }
        return new User(this.id, this.email, this._name, to, this.createdAt)
      }
    }
    ```
    
    ### Value Objects
    ```typescript
    // domain/shared/value-objects/email.vo.ts
    export class Email {
      private constructor(public readonly value: string) {}
    
      static create(raw: string): Email {
        const normalized = raw.toLowerCase().trim()
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
          throw new DomainError(`Invalid email: ${raw}`)
        }
        return new Email(normalized)
      }
    
      equals(other: Email): boolean {
        return this.value === other.value
      }
    
      toString(): string {
        return this.value
      }
    }
    
    // domain/shared/value-objects/money.vo.ts
    export class Money {
      private constructor(
        public readonly amount: number,  // in cents
        public readonly currency: string,
      ) {}
    
      static of(amount: number, currency: string): Money {
        if (amount < 0) throw new DomainError('Amount cannot be negative')
        if (!['USD', 'EUR', 'GBP', 'TRY'].includes(currency)) {
          throw new DomainError(`Unsupported currency: ${currency}`)
        }
        return new Money(Math.round(amount), currency)
      }
    
      add(other: Money): Money {
        if (this.currency !== other.currency) {
          throw new DomainError('Cannot add different currencies')
        }
        return Money.of(this.amount + other.amount, this.currency)
      }
    
      format(): string {
        return new Intl.NumberFormat('en-US', {
          style: 'currency',
          currency: this.currency,
        }).format(this.amount / 100)
      }
    }
    ```
    
    ### Domain Events
    ```typescript
    // domain/shared/domain-event.ts
    export abstract class DomainEvent {
      public readonly occurredAt: Date
      public readonly eventId: string
    
      constructor(
        public readonly aggregateId: string,
        public readonly eventType: string,
      ) {
        this.occurredAt = new Date()
        this.eventId    = crypto.randomUUID()
      }
    }
    
    // domain/user/events/user-created.event.ts
    export class UserCreatedEvent extends DomainEvent {
      constructor(
        public readonly userId: string,
        public readonly email: string,
        public readonly name: string,
      ) {
        super(userId, 'user.created')
      }
    }
    ```
    
    ## Application Layer
    
    ### Use Cases (Command/Query pattern)
    ```typescript
    // application/users/commands/create-user/create-user.command.ts
    export class CreateUserCommand {
      constructor(
        public readonly email: string,
        public readonly password: string,
        public readonly name: string,
        public readonly actorId: string,
      ) {}
    }
    
    // application/users/commands/create-user/create-user.handler.ts
    import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs'
    
    @CommandHandler(CreateUserCommand)
    export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
      constructor(
        private readonly userRepo:     IUserRepository,
        private readonly hasher:       IPasswordHasher,
        private readonly eventBus:     EventBus,
      ) {}
    
      async execute(cmd: CreateUserCommand): Promise<UserId> {
        const email = Email.create(cmd.email)
    
        const exists = await this.userRepo.existsByEmail(email)
        if (exists) throw new ConflictException('Email already in use')
    
        const passwordHash = await this.hasher.hash(cmd.password)
        const userId       = UserId.generate()
    
        const user = User.create({
          id:    userId,
          email,
          name:  cmd.name,
          passwordHash,
        })
    
        await this.userRepo.save(user)
        this.eventBus.publish(new UserCreatedEvent(userId.value, email.value, cmd.name))
    
        return userId
      }
    }
    ```
    
    ## Repository Pattern (Interface in Domain)
    
    ```typescript
    // domain/user/user.repository.interface.ts
    export interface IUserRepository {
      findById(id: UserId): Promise<User | null>
      findByEmail(email: Email): Promise<User | null>
      existsByEmail(email: Email): Promise<boolean>
      save(user: User): Promise<void>
      delete(id: UserId): Promise<void>
    }
    
    // infrastructure/persistence/typeorm/user.typeorm-repository.ts
    @Injectable()
    export class UserTypeOrmRepository implements IUserRepository {
      constructor(
        @InjectRepository(UserOrmEntity)
        private readonly ormRepo: Repository<UserOrmEntity>,
        private readonly mapper:  UserMapper,
      ) {}
    
      async findById(id: UserId): Promise<User | null> {
        const row = await this.ormRepo.findOne({ where: { id: id.value } })
        return row ? this.mapper.toDomain(row) : null
      }
    
      async save(user: User): Promise<void> {
        const row = this.mapper.toOrm(user)
        await this.ormRepo.save(row)
      }
      // ...
    }
    ```
    
    ## SOLID in Practice
    
    ### Single Responsibility
    ```typescript
    // Bad: UserService does too much
    class UserService {
      async register(dto) { /* creates user + sends email + logs audit */ }
      async updateProfile(dto) { /* validates + updates + notifies */ }
      async generateReport() { /* queries DB + formats CSV + sends email */ }
    }
    
    // Good: each class has one reason to change
    class UserRegistrationService { /* only: validate, create, emit event */ }
    class EmailNotificationService { /* only: send emails */ }
    class AuditLogService { /* only: write audit entries */ }
    class UserReportService { /* only: query, format, export */ }
    ```
    
    ### Open/Closed
    ```typescript
    // Open for extension, closed for modification
    interface NotificationChannel {
      send(message: NotificationMessage): Promise<void>
    }
    
    class EmailChannel implements NotificationChannel { /* ... */ }
    class SmsChannel   implements NotificationChannel { /* ... */ }
    class SlackChannel implements NotificationChannel { /* ... */ }
    
    class NotificationService {
      constructor(private channels: NotificationChannel[]) {}
    
      // No modification needed when adding a new channel
      async notify(message: NotificationMessage) {
        await Promise.all(this.channels.map(c => c.send(message)))
      }
    }
    ```
    
    ### Dependency Inversion
    ```typescript
    // Domain doesn't depend on infrastructure
    // Bad:
    class OrderService {
      private repo = new TypeOrmOrderRepository() // concrete dep!
    }
    
    // Good:
    @Injectable()
    class OrderService {
      constructor(
        @Inject(ORDER_REPOSITORY_TOKEN)
        private readonly repo: IOrderRepository, // interface dep
      ) {}
    }
    ```
    
    ## Module Design
    
    ```typescript
    // users/users.module.ts
    @Module({
      imports: [
        TypeOrmModule.forFeature([UserOrmEntity]),
        CqrsModule,
        ConfigModule,
      ],
      controllers: [UsersController],
      providers: [
        // Application
        CreateUserHandler,
        GetUserQueryHandler,
        // Domain services
        UserDomainService,
        // Infrastructure adapters
        {
          provide:  IUserRepository,   // injection token
          useClass: UserTypeOrmRepository,
        },
        {
          provide:  IPasswordHasher,
          useClass: BcryptPasswordHasher,
        },
      ],
      exports: [IUserRepository],  // only export what other modules need
    })
    export class UsersModule {}
    ```
    
    ## Event-Driven Architecture
    
    ```typescript
    // Sagas coordinate cross-module workflows
    @Injectable()
    export class UserOnboardingSaga {
      @Saga()
      userCreated = (events$: Observable<unknown>): Observable<ICommand> => {
        return events$.pipe(
          ofType(UserCreatedEvent),
          map(event => new SendWelcomeEmailCommand(event.email, event.name)),
        )
      }
    }
    ```
    
    ## ADR (Architecture Decision Record) Template
    
    ```markdown
    # ADR-001: Use CQRS Pattern for Write-Heavy Modules
    
    ## Status
    Accepted
    
    ## Context
    Orders and inventory modules have complex write operations with multiple side effects.
    Read and write models diverge significantly.
    
    ## Decision
    Adopt CQRS using @nestjs/cqrs for these modules.
    Simple CRUD modules (users, settings) remain using direct service calls.
    
    ## Consequences
    + Clear separation of read/write models
    + Easier to add event sourcing later
    + Better testability via command/query handlers
    - Higher initial complexity
    - Two data models to maintain in some cases
    ```
    
    ## Forbidden Patterns
    
    - Never have circular dependencies between modules — restructure into shared modules
    - Never access the database from the domain layer — only through repository interfaces
    - Never put I/O (HTTP, DB, file system) in domain entities or value objects
    - Never use `static` mutable state in services — it breaks testability and concurrency
    - Never expose ORM entities directly to the API layer — map to DTOs
    - Never put business rules in controllers — they belong in the domain or application layer
    - Never use inheritance where composition would work — prefer interfaces and DI
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related