kitlangton / kitlangton/effect-solutions

[Feature request] Showcase how to best utilise effect RPC & effect SQL

Open
#32 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
442
Forks
22
PR merge metrics
No merged PRs in 30d

Description

- RPC is really powerful, having docs how to best utilise this would be really useful
- Effect SQL is insanely good and fits into the effect ecosystem so well, it's a no brainer to use in your projects. Some really good docs on how to best utilise it would be nice - Particularly around the nice patterns you can use with Model - They have really thought out the Model architecture so well, the result is a clean coherent way to model your domain entities/value objects and be able to use these across your different application layers (i.e. built in support for exempting fields from your presentation layer)

If this is something you'd consider adding in, let me know and I'd be happy to put something together for either

---

Here's an example of my domain-driven-design agent doc which is simple and centered around the domain entities being defined with effect Model - This is just an example, if effect-solutions were to include it you'd create docs without explicitly defining how to structure your application

## Layer Structure

```
src/
├── domain/ # DOMAIN LAYER - Pure business logic
│ ├── {entity}/
│ │ ├── {entity}.ts # Model.Class with behavior
│ │ └── index.ts # Public exports
│ └── shared/
│ ├── branded-types.ts # UserId, OrgId, etc.
│ └── value-objects.ts # Reusable value objects

├── infrastructure/ # PERSISTENCE LAYER
│ └── repository/
│ └── {entity}/
│ ├── {entity}-repository.ts # Repository service
│ └── index.ts # Repository exports only

├── application/ # APPLICATION LAYER
│ └── {domain}/
│ └── services/
│ └── {service}.ts # Use case orchestration

└── presentation/ # PRESENTATION LAYER
└── rpc/
├── {entity}-rpc.ts # RPC contracts
└── {entity}-rpc-impl.ts # RPC handlers
```

## Key Principles

### 1. Domain Layer is Independent

The domain layer (`src/domain/`) should:
- **NOT** import from `infrastructure/`, `application/`, or `presentation/`
- Define pure business logic with no side effects
- Use `Model.Class` from `@effect/sql` for entity definitions
- Include computed getters, static factories, and domain methods

### 2. Dependencies Flow Inward

```
presentation → application → infrastructure → domain

domain
```

- Presentation depends on Application
- Application depends on Infrastructure and Domain
- Infrastructure depends on Domain
- Domain depends on nothing (except Effect/Schema)

### 3. Repository Does NOT Re-export Domain

The repository `index.ts` should only export repository-specific items:

```typescript
// src/infrastructure/repository/cookie-consent/index.ts

// Export repository service and errors
export {
CookieConsentRepository,
CookieConsentNotFoundError,
CookieConsentDbError
} from "./cookie-consent-repository"

// DO NOT re-export domain models here
// Consumers should import domain models directly from src/domain/
```

Consumers import domain models directly:
```typescript
// In application or presentation layer
import { CookieConsent, CookieConsentId } from "@/domain/cookie-consent"
import { CookieConsentRepository } from "@/infrastructure/repository/cookie-consent"
```

---

## Domain Layer Patterns

### Entity Definition with Model.Class

Domain entities use `Model.Class` with field access control:

```typescript
// src/domain/cookie-consent/cookie-consent.ts
import { Schema } from "effect"
import { Model } from "@effect/sql"

// Branded ID types for type safety
export const CookieConsentId = Schema.String.pipe(Schema.brand("CookieConsentId"))
export type CookieConsentId = Schema.Schema.Type

export const UserId = Schema.String.pipe(Schema.brand("UserId"))
export type UserId = Schema.Schema.Type

export class CookieConsent extends Model.Class("CookieConsent")({
// DB-generated fields
id: Model.Generated(CookieConsentId),

// Auth-derived fields - clients can NEVER set these
userId: UserId.pipe(Model.FieldExcept("jsonCreate", "jsonUpdate")),

// User-settable fields
essential: Schema.Boolean,
analytics: Schema.Boolean,
performance: Schema.Boolean,

// Server-injected audit fields
ipCountry: Schema.NullOr(Schema.String).pipe(
Model.FieldExcept("jsonCreate", "jsonUpdate")
),
userAgent: Schema.NullOr(Schema.String).pipe(
Model.FieldExcept("jsonCreate", "jsonUpdate")
),

// Timestamps - server managed
consentedAt: Model.DateTimeInsertFromDate,
createdAt: Model.DateTimeInsertFromDate,
updatedAt: Model.DateTimeUpdateFromDate
}) {
// Static array schemas for convenience
static array = Schema.Array(CookieConsent)
static arrayJson = Schema.Array(CookieConsent.json)
}
```

### Domain Index File

```typescript
// src/domain/cookie-consent/index.ts
export {
CookieConsent,
CookieConsentId,
UserId,
type CookieConsentId as CookieConsentIdType,
type UserId as UserIdType
} from "./cookie-consent"
```

### Shared Branded Types

```typescript
// src/domain/shared/branded-types.ts
import { Schema } from "effect"

/**
* Clerk user ID - branded for type safety
*/
export const UserId = Schema.String.pipe(
Schema.brand("UserId"),
Schema.annotations({ description: "Description on userId" })
)
export type UserId = Schema.Schema.Type

/**
* Organization ID - branded for type safety
*/
export const OrgId = Schema.String.pipe(
Schema.brand("OrgId"),
Schema.annotations({ description: "Organization ID (format: org_xxxxxxxxxxxx)" })
)
export type OrgId = Schema.Schema.Type
```

---

## Field Access Control Reference

### When to Use Each Modifier

| Scenario | Modifier |
|----------|----------|
| DB auto-generates the value | `Model.Generated(schema)` |
| App generates before insert (UUID) | `Model.GeneratedByApp(schema)` |
| Timestamp set once on creation | `Model.DateTimeInsertFromDate` |
| Timestamp updated on every write | `Model.DateTimeUpdateFromDate` |
| Sensitive data (passwords) | `Model.Sensitive(schema)` |
| JSON column in database | `Model.JsonFromString(schema)` |
| **Auth-derived field (userId, orgId)** | `schema.pipe(Model.FieldExcept("jsonCreate", "jsonUpdate"))` |
| **Server-injected (IP, user agent)** | `schema.pipe(Model.FieldExcept("jsonCreate", "jsonUpdate"))` |
| Different encoding DB vs API | `Model.Field({ select: ..., json: ... })` |
| Optional only for some operations | `schema.pipe(Model.fieldEvolve({ insert: Schema.optional }))` |

### Result of Field Access Control

With proper field modifiers, the generated schemas automatically enforce security:

```typescript
// CookieConsent.jsonCreate automatically contains ONLY:
// { essential, analytics, performance }

// CookieConsent.jsonUpdate automatically contains ONLY:
// { essential, analytics, performance }

// CookieConsent.json contains ALL fields for responses:
// { id, userId, essential, analytics, performance, ipCountry, userAgent, ... }
```

No manual `S.omit()` needed in RPC contracts.

---

## Repository Layer Pattern

The repository imports from domain and provides database operations:

```typescript
// src/infrastructure/repository/cookie-consent/cookie-consent-repository.ts
import { Effect, Option, Schema as S } from "effect"
import { Model, SqlSchema } from "@effect/sql"
import { PgClient } from "@effect/sql-pg"

// Import domain model directly
import { CookieConsent, UserId, CookieConsentId } from "@/domain/cookie-consent"

// Repository-specific errors
export class CookieConsentNotFoundError extends S.TaggedError()(
"CookieConsentNotFoundError",
{ message: S.String, userId: S.String }
) {}

export class CookieConsentDbError extends S.TaggedError()(
"CookieConsentDbError",
{ message: S.String, cause: S.Unknown, operation: S.String }
) {}

export class CookieConsentRepository extends Effect.Service()(
"Infrastructure/CookieConsentRepository",
{
accessors: true,
effect: Effect.gen(function* () {
const sql = yield* PgClient.PgClient

// Standard CRUD from Model.makeRepository
const repo = yield* Model.makeRepository(CookieConsent, {
tableName: "user_cookie_consent",
idColumn: "id",
spanPrefix: "CookieConsentRepository"
})

// Custom queries using SqlSchema
const findByUserId = SqlSchema.findOne({
Request: UserId,
Result: CookieConsent,
execute: (userId) => sql`
SELECT * FROM user_cookie_consent
WHERE user_id = ${userId}
`
})

return {
...repo,
findByUserId
} as const
})
}
) {}
```

### Repository Index File

```typescript
// src/infrastructure/repository/cookie-consent/index.ts

// Export repository service and errors ONLY
export {
CookieConsentRepository,
CookieConsentNotFoundError,
CookieConsentDbError
} from "./cookie-consent-repository"

// DO NOT re-export domain models
// Consumers import directly: import { CookieConsent } from "@/domain/cookie-consent"
```

---

## Presentation Layer Pattern

RPC contracts derive schemas directly from domain models:

```typescript
// src/presentation/rpc/cookie-consent-rpc.ts
import { Rpc, RpcGroup } from "@effect/rpc"
import { Schema as S } from "effect"
import { CookieConsent } from "@/domain/cookie-consent"

// Request/response schemas derive from Model schemas
const GetConsentResponse = CookieConsent.json

const UpdateConsentRequest = CookieConsent.jsonUpdate // Auto-excludes userId, timestamps!

const UpdateConsentResponse = CookieConsent.json

// RPC group definition
export const CookieConsentRpcGroup = RpcGroup.make(
Rpc.effect("getConsent", {
success: GetConsentResponse,
failure: S.Never
}),
Rpc.effect("updateConsent", {
payload: UpdateConsentRequest, // Only user-settable fields!
success: UpdateConsentResponse,
failure: S.Never
})
)
```

---

## Migration Path

For existing code that has models in `src/infrastructure/repository/*/models/`:

### Phase 1: Create Domain Layer
1. Create `src/domain/{entity}/` directory
2. Move model definition from `models/` to domain
3. Add field access control modifiers

### Phase 2: Update Imports
1. Update repository to import from `@/domain/`
2. Update application services to import from `@/domain/`
3. Update RPC contracts to use `Model.jsonCreate`/`Model.jsonUpdate`

### Phase 3: Clean Up
1. Remove re-exports from repository `index.ts`
2. Delete empty `models/` directory
3. Update any remaining imports

---

## Benefits

1. **Security by Default**: Auth-derived fields automatically excluded from client inputs
2. **Single Source of Truth**: Domain model defines all field visibility rules
3. **No Manual Filtering**: RPC contracts use `Model.jsonCreate`/`Model.jsonUpdate` directly
4. **Rich Domain Models**: Computed getters and factory methods live with the data
5. **Clear Dependencies**: Domain layer has no external dependencies
6. **Testability**: Domain logic can be tested without infrastructure

Contributor guide

Open the contributing guide

Research direction

Start by reviewing the existing documentation structure, then compare the proposed paths under src/domain/, src/infrastructure/repository/, and src/presentation/rpc/ with the current project. Cover practical Effect RPC and Effect SQL Model usage without requiring the example's application structure; done means the guidance is integrated, scoped, and includes clear examples for field access and repository patterns.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
documentation
Issue type
Documentation
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.