home / skills / toilahuongg / shopify-agents-kit / clean-architecture-ts

clean-architecture-ts skill

/.claude/skills/clean-architecture-ts

This skill guides implementing clean architecture in Remix/TypeScript apps, separating web, service, and repository layers for maintainable, testable code.

npx playbooks add skill toilahuongg/shopify-agents-kit --skill clean-architecture-ts

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
3.3 KB
---
name: clean-architecture-ts
description: Best practices for implementing Clean Architecture in Remix/TypeScript apps. Covers Service Layer, Repository Pattern, and Dependency Injection.
---

# Clean Architecture for Remix/TypeScript Apps

As Remix apps grow, `loader` and `action` functions can become bloated "God Functions". This skill emphasizes separation of concerns.

## 1. The Layers

### A. The Web Layer (Loaders/Actions)
**Responsibility**: Parsing requests, input validation (Zod), and returning Responses (JSON/Redirect).
**Rule**: NO business logic here. Only orchestration.

```typescript
// app/routes/app.products.update.ts
export const action = async ({ request }: ActionFunctionArgs) => {
  const { shop } = await authenticate.admin(request);
  const formData = await request.formData();
  
  // 1. Validate Input
  const input = validateProductUpdate(formData);

  // 2. Call Service
  const updatedProduct = await ProductService.updateProduct(shop, input);

  // 3. Return Response
  return json({ product: updatedProduct });
};
```

### B. The Service Layer (Business Logic)
**Responsibility**: The "What". Rules, calculations, error handling, complex flows.
**Rule**: Framework agnostic. Should not know about "Request" or "Response" objects.

```typescript
// app/services/product.service.ts
export class ProductService {
  static async updateProduct(shop: string, input: ProductUpdateInput) {
    // Business Rule: Can't update archived products
    const existing = await ProductRepository.findByShopAndId(shop, input.id);
    if (existing.status === 'ARCHIVED') {
      throw new BusinessError("Cannot update archived product");
    }

    // Business Logic
    const result = await ProductRepository.save({
      ...existing,
      ...input,
      updatedAt: new Date()
    });

    return result;
  }
}
```

### C. The Repository Layer (Data Access)
**Responsibility**: The "How". interaction with Database (Prisma), APIs (Shopify Admin), or File System.
**Rule**: Only this layer touches the DB/API.

```typescript
// app/repositories/product.repository.ts
export class ProductRepository {
  static async findByShopAndId(shop: string, id: string) {
    return prisma.product.findFirstOrThrow({
      where: { shop, id: BigInt(id) }
    });
  }
}
```

## 2. Directory Structure

```
app/
  routes/         # Web Layer
  services/       # Business Logic
  repositories/   # Data Access (DB/API)
  models/         # Domain Types / Interfaces
  utils/          # Pure functions (math, string manipulation)
```

## 3. Dependency Injection (Optional but Recommended)
For complex apps, use a container like `tsyringe` to manage dependencies, especially for testing (mocking Repositories).

```typescript
// app/services/order.service.ts
@injectable()
export class OrderService {
  constructor(
    @inject(OrderRepository) private orderRepo: OrderRepository,
    @inject(ShopifyClient) private shopify: ShopifyClient
  ) {}
}
```

## 4. Error Handling
Create custom Error classes to differentiate between "Bad Request" (User error) and "Server Error" (System error).

```typescript
// app/errors/index.ts
export class BusinessError extends Error {
  public code = 422;
}

export class NotFoundError extends Error {
  public code = 404;
}
```
Refactor your `loader`/`action` to catch these errors and return appropriate HTTP status codes.

Overview

This skill teaches applying Clean Architecture to Remix + TypeScript apps to keep loaders and actions thin. It prescribes a Web layer for request orchestration, a Service layer for business rules, and a Repository layer for data access, plus optional dependency injection and structured error handling. Followed correctly, it improves testability, maintainability, and separation of concerns.

How this skill works

Inspect your route handlers and move parsing/validation and response shaping into the Web layer only. Implement business logic inside services that are framework-agnostic and call repositories for any database or external API operations. Optionally register services and repositories in a DI container to swap implementations in tests and composition roots.

When to use it

  • When route loader/action functions become long or contain business rules
  • When you need clearer boundaries for testing complex flows
  • When multiple routes share the same business logic
  • When you integrate external APIs or multiple data sources and want a single abstraction
  • When you want consistent error handling and HTTP status mapping

Best practices

  • Keep Web layer limited to request parsing (Zod), auth, orchestration, and returning Responses
  • Encapsulate business rules and flows in Services; never access Request/Response there
  • Let Repositories be the only place that touches Prisma, HTTP clients, or the filesystem
  • Use DI (e.g., tsyringe) for complex apps to inject repositories into services for easier mocking
  • Define custom error classes (BusinessError, NotFoundError) and map them to HTTP codes in routes
  • Organize code by layer folders: routes/, services/, repositories/, models/, utils/

Example use cases

  • Updating a product: action validates input, calls ProductService.updateProduct, returns json
  • Order processing: OrderService orchestrates inventory check, payment, and fulfillment repositories
  • Testing: swap real Repositories for in-memory mocks via DI to run fast unit tests
  • External API integration: create ShopifyClient inside repositories and keep service logic unchanged
  • Incremental refactor: extract business logic from bloated loaders into services one endpoint at a time

FAQ

Do services know about Request/Response objects?

No. Services must be framework-agnostic and accept primitive inputs or domain types only.

Is dependency injection required?

No. DI is optional but recommended for complex apps or when you need easy mocking and composition.