# Dev Playbook — Full Documentation
> The complete Dev Playbook, concatenated for LLM ingestion.
> Compact index: https://devplaybook.tansuasici.com/llms.txt
> Site: https://devplaybook.tansuasici.com
> Repo: https://github.com/tansuasici/dev-playbook
---
# Introduction
> Opinionated engineering playbook for modern software teams.
Source: https://devplaybook.tansuasici.com/docs
Dev Playbook
An opinionated engineering playbook — conventions, architecture decisions, processes, and checklists for modern software teams.
[](https://github.com/tansuasici/dev-playbook/blob/main/LICENSE)
[](https://github.com/tansuasici/dev-playbook/actions/workflows/lint.yml)
[](https://github.com/tansuasici/dev-playbook/blob/main/VERSION)
Personal software development playbook. Defines how I build software — from branching strategy to code conventions.
**Purpose**: Every new project reads this repo first and follows these standards. No reinventing the wheel.
## Table of Contents
- [How to Use](#how-to-use)
- [Structure](#structure)
- [Quick Reference](#quick-reference)
- [Principles](#principles)
- [Contributing](#contributing)
- [License](#license)
## How to Use
### For Claude Code / AI Assistants
Add this to your project's `CLAUDE.md`:
```markdown
## Development Process
Follow the playbook at https://github.com/tansuasici/dev-playbook
Read all files under process/ and conventions/ before starting work.
Apply templates/ when creating issues and PRs.
```
Or at the start of a session:
> "Read the dev-playbook repo first, then work on this project."
### For Yourself
Bookmark this repo. Before starting a new project, run through `checklists/new-project-setup.md`.
---
## Structure
```text
dev-playbook/
process/ How work flows
branching.md Branch strategy and naming
commits.md Conventional Commits rules
issues.md Issue creation and lifecycle
pull-requests.md PR process and review outcomes
board-workflow.md Kanban board flow
ci-cd.md CI/CD pipeline standards
code-review.md How to review code
incident-response.md What to do when production breaks
dependency-management.md Adding, updating, removing deps
conventions/ How code is written
dotnet.md .NET / C# standards
nextjs.md Next.js / React standards
python.md Python standards
naming.md Naming conventions (all languages)
api-design.md REST API design standards
api-error-format.md Standard error response format (RFC 7807)
error-handling.md Exception handling patterns
testing.md Testing philosophy and practices
logging.md Logging levels, structured logging
observability.md Metrics, tracing, alerting, health checks
performance.md Response budgets, caching, N+1 prevention
docker.md Dockerfile and Compose best practices
git-hooks.md Pre-commit hooks setup
environment-management.md Environment config, feature flags, secrets
documentation.md README template, API docs, inline comments
accessibility.md WCAG 2.1 AA, keyboard nav, screen readers
i18n.md Internationalization and localization
architecture/ How systems are designed
decisions.md Architecture Decision Records (ADR)
multi-tenancy.md Multi-tenancy with RLS
security.md Security standards and OWASP
database.md Schema design, migrations, indexing
templates/ Copy-paste templates
issue-feature.md Feature request template
issue-bug.md Bug report template
pull-request.md PR template
claude-md-starter.md CLAUDE.md starter for new projects
checklists/ Step-by-step checklists
new-project-setup.md New project bootstrap
pre-merge.md Pre-merge review
release.md Release process
security-audit.md Periodic security audit
onboarding.md New developer onboarding
.github/ GitHub integration
ISSUE_TEMPLATE/ Native issue templates (bug, feature)
PULL_REQUEST_TEMPLATE.md Native PR template
workflows/lint.yml Markdown lint + broken link check
labels.yml Standard GitHub label set
CONTRIBUTING.md How to contribute
CODE_OF_CONDUCT.md Community standards
CHANGELOG.md Version history
VERSION Current version (semver)
LICENSE MIT License
```
## Quick Reference
| I want to... | Read this |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Start a new project | [checklists/new-project-setup.md](/docs/checklists/new-project-setup) |
| Onboard to a project | [checklists/onboarding.md](/docs/checklists/onboarding) |
| Create a feature branch | [process/branching.md](/docs/process/branching) |
| Write a commit message | [process/commits.md](/docs/process/commits) |
| Open an issue | [process/issues.md](/docs/process/issues) + [templates/](https://github.com/tansuasici/dev-playbook/tree/main/templates) |
| Open a PR | [process/pull-requests.md](/docs/process/pull-requests) |
| Review code | [process/code-review.md](/docs/process/code-review) |
| Set up CI/CD | [process/ci-cd.md](/docs/process/ci-cd) |
| Handle a production incident | [process/incident-response.md](/docs/process/incident-response) |
| Manage dependencies | [process/dependency-management.md](/docs/process/dependency-management) |
| Design a REST API | [conventions/api-design.md](/docs/conventions/api-design) |
| Handle API errors | [conventions/api-error-format.md](/docs/conventions/api-error-format) |
| Handle exceptions | [conventions/error-handling.md](/docs/conventions/error-handling) |
| Write .NET code | [conventions/dotnet.md](/docs/conventions/dotnet) |
| Write Next.js code | [conventions/nextjs.md](/docs/conventions/nextjs) |
| Write Python code | [conventions/python.md](/docs/conventions/python) |
| Write tests | [conventions/testing.md](/docs/conventions/testing) |
| Set up logging | [conventions/logging.md](/docs/conventions/logging) |
| Set up monitoring | [conventions/observability.md](/docs/conventions/observability) |
| Optimize performance | [conventions/performance.md](/docs/conventions/performance) |
| Write a Dockerfile | [conventions/docker.md](/docs/conventions/docker) |
| Set up git hooks | [conventions/git-hooks.md](/docs/conventions/git-hooks) |
| Manage environments | [conventions/environment-management.md](/docs/conventions/environment-management) |
| Write documentation | [conventions/documentation.md](/docs/conventions/documentation) |
| Make it accessible | [conventions/accessibility.md](/docs/conventions/accessibility) |
| Add i18n support | [conventions/i18n.md](/docs/conventions/i18n) |
| Design a database | [architecture/database.md](/docs/architecture/database) |
| Design a multi-tenant system | [architecture/multi-tenancy.md](/docs/architecture/multi-tenancy) |
| Review before merge | [checklists/pre-merge.md](/docs/checklists/pre-merge) |
| Prepare a release | [checklists/release.md](/docs/checklists/release) |
| Run a security audit | [checklists/security-audit.md](/docs/checklists/security-audit) |
## Principles
1. **Every change has an issue** — No ad-hoc work. Track everything.
2. **Every issue has a branch** — Never push directly to main.
3. **Every branch has a PR** — Even solo. PRs are your audit trail.
4. **CI must pass before merge** — No exceptions. Tests are not optional.
5. **Keep commits atomic** — One concern per commit. Easy to bisect, easy to revert.
6. **Simplicity over cleverness** — Make it work, make it right, then make it fast.
## Contributing
Contributions are welcome! See [CONTRIBUTING.md](/docs/contributing) for guidelines.
## License
This project is licensed under the [MIT License](https://github.com/tansuasici/dev-playbook/blob/main/LICENSE).
---
# Contributing
> How to contribute to Dev Playbook.
Source: https://devplaybook.tansuasici.com/docs/contributing
First off, thanks for taking the time to contribute! This playbook is meant to be a living document — improvements, corrections, and new guides are always welcome.
## How to Contribute
### Reporting Issues
- Use the **Bug Report** template for errors, broken links, or incorrect information
- Use the **Feature Request** template for suggesting new guides or improvements
- Check existing issues first to avoid duplicates
### Suggesting Changes
1. **Fork** the repository
2. **Create a branch**: `docs/` (e.g., `docs/add-graphql-guide`)
3. **Make your changes** following the style guide below
4. **Submit a PR** with a clear description of what you changed and why
### What We're Looking For
- **New guides** for technologies or practices not yet covered
- **Corrections** to existing content (outdated info, broken examples)
- **Better examples** — real-world, practical code snippets
- **Translations** — making the playbook accessible in other languages
- **Typo fixes** — even small improvements matter
## Style Guide
### File Structure
Every guide follows this pattern:
```markdown
# Title
## Section (with table if applicable)
| Column | Column |
|--------|--------|
### Subsection
- Bullet points for rules
- Code blocks for examples
## Anti-Patterns (if applicable)
What NOT to do, with examples.
```
### Writing Style
- **Be direct** — lead with the rule, then explain why
- **Use tables** for comparisons and reference material
- **Include code examples** in all relevant languages (.NET, TypeScript, Python)
- **Show good AND bad** — `// GOOD` and `// BAD` patterns
- **Keep it practical** — real-world examples over theoretical explanations
- **No fluff** — every sentence should add value
### Code Examples
- Use fenced code blocks with language identifiers
- Include examples for .NET (C#), TypeScript, and Python where applicable
- Keep examples minimal but complete — enough to understand the concept
- Use realistic variable names and scenarios
### Naming Conventions
- **Files**: `kebab-case.md` (e.g., `api-design.md`, `error-handling.md`)
- **Headings**: Title Case for H1, Sentence case for H2+
- **Folders**: lowercase, single word when possible
## Review Process
1. All PRs require at least one review
2. Reviewers check for accuracy, clarity, and consistency with existing guides
3. Discussions are welcome — suggest improvements, ask questions
4. Once approved, maintainer merges with squash
## Code of Conduct
This project follows the [Contributor Covenant Code of Conduct](https://github.com/tansuasici/dev-playbook/blob/main/CODE_OF_CONDUCT.md). By participating, you agree to uphold this code.
## Questions?
Open an issue with the label `question` — happy to help.
---
# Architecture Decision Records
> How we document and track architectural decisions.
Source: https://devplaybook.tansuasici.com/docs/architecture/decisions
## How to Use This File
When making a significant technical decision, document it here. Future-you (and AI assistants) will thank you.
## Format
```markdown
### ADR-NNN: Title
**Date**: YYYY-MM-DD
**Status**: Accepted | Superseded | Deprecated
**Context**: Why did this decision come up?
**Decision**: What did you decide?
**Consequences**: What are the trade-offs?
```
---
### ADR-001: Clean Architecture for Backend
**Date**: Project start
**Status**: Accepted
**Context**: Need a maintainable, testable backend structure that separates concerns.
**Decision**: Use Clean Architecture with 4 layers: Domain → Application → Infrastructure → Api.
**Consequences**:
- (+) Domain logic is framework-independent and highly testable
- (+) Infrastructure can be swapped (e.g., change ORM, change DB)
- (-) More files and indirection for simple CRUD operations
- (-) New developers need to understand the layer boundaries
### ADR-002: CQRS with MediatR
**Date**: Project start
**Status**: Accepted
**Context**: Need clear separation between read and write operations for maintainability.
**Decision**: Use MediatR for command/query dispatch. One handler per use case.
**Consequences**:
- (+) Each use case is isolated and testable
- (+) Easy to add cross-cutting concerns (validation, logging) via behaviors
- (-) Boilerplate: Command + Handler + Validator per operation
- (-) Overkill for simple CRUD, but consistency wins
### ADR-003: Mapster over AutoMapper
**Date**: Project start
**Status**: Accepted
**Context**: Need object mapping between DTOs and entities.
**Decision**: Use Mapster instead of AutoMapper.
**Consequences**:
- (+) No commercial license issues (AutoMapper changed to commercial)
- (+) Better performance (compile-time mapping)
- (+) Similar API, easy to learn
- (-) Smaller community than AutoMapper
---
## Template for New Decisions
Copy this when adding a new ADR:
```markdown
### ADR-NNN: Title
**Date**: YYYY-MM-DD
**Status**: Accepted
**Context**:
**Decision**:
**Consequences**:
- (+)
- (-)
```
## Categories of Decisions to Document
- Programming language or framework choices
- Database selections (relational, vector, cache, analytics)
- Authentication/authorization approach
- API design (REST, GraphQL, gRPC)
- AI/ML service provider choices
- Hosting and deployment strategy
- Third-party service integrations
- Library choices where multiple options exist
---
# Database Patterns
> Database design conventions and migration strategies.
Source: https://devplaybook.tansuasici.com/docs/architecture/database
## Schema Design
### Naming
| Element | Convention | Example |
| ------------------ | ---------------------- | ----------------------------------- |
| Tables | PascalCase, plural | `Courses`, `EnrollmentRecords` |
| Columns | PascalCase | `CreatedAt`, `TenantId`, `IsActive` |
| Primary keys | `Id` (Guid or int) | `Id` |
| Foreign keys | `Id` | `CourseId`, `UserId` |
| Indexes | `IX_
_` | `UQ_Users_Email_TenantId` |
| FK constraints | `FK__` | `FK_Enrollments_Courses` |
### Standard Columns
Every table should have these base columns:
```sql
Id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
TenantId UUID NOT NULL, -- multi-tenancy (if applicable)
CreatedAt TIMESTAMPTZ NOT NULL DEFAULT now(),
UpdatedAt TIMESTAMPTZ NULL,
CreatedBy UUID NULL, -- optional: who created it
IsDeleted BOOLEAN NOT NULL DEFAULT false -- soft delete (if needed)
```
### Data Types
| Use | PostgreSQL | Avoid |
| ---------- | --------------------------- | --------------------------------------------------- |
| IDs | `UUID` | `SERIAL` (unless performance-critical) |
| Timestamps | `TIMESTAMPTZ` | `TIMESTAMP` (no timezone = bugs) |
| Money | `DECIMAL(18,2)` | `FLOAT`, `REAL` (precision loss) |
| Booleans | `BOOLEAN` | `INT` with 0/1 |
| Enums | `SMALLINT` + app-level enum | `VARCHAR` (typo-prone), `PG ENUM` (hard to migrate) |
| Text | `VARCHAR(N)` with limit | `TEXT` without limit (unbounded = risk) |
| JSON | `JSONB` | `JSON` (not indexable) |
## Migrations
### Rules
1. **Always use migrations** — Never `EnsureCreated()` or manual DDL in production
2. **Migrations are forward-only** — Never edit a migration after it has been applied
3. **One migration per change** — Don't bundle unrelated schema changes
4. **Test rollback** — Every migration should be reversible (down migration)
5. **Name descriptively** — `AddMaxCapacityToCourses`, not `Update1` or `Migration_003`
### Dangerous Operations
These require extra caution (can cause downtime or data loss):
| Operation | Risk | Safe Alternative |
| ------------------------ | ----------------------- | ------------------------------------------------------- |
| Drop column | Data loss | Add `IsDeleted`/rename first, drop later |
| Rename column | Breaks running code | Add new column → copy data → drop old (multi-step) |
| Add NOT NULL column | Fails if table has data | Add as nullable → backfill → alter to NOT NULL |
| Change column type | Data truncation | Add new column → migrate data → swap |
| Drop table | Data loss | Rename to `_deprecated_` first, drop after verification |
| Add index on large table | Locks table | `CREATE INDEX CONCURRENTLY` (PostgreSQL) |
### Migration in CI
- Run migrations as part of the deployment pipeline
- Test migrations against a copy of production data (or realistic test data)
- Never run migrations manually in production — always automated
## Indexing
### When to Add an Index
- Columns used in `WHERE` clauses frequently
- Columns used in `JOIN` conditions
- Columns used in `ORDER BY`
- Foreign key columns (PostgreSQL doesn't auto-index FKs)
- `TenantId` — always index this (appears in every query)
### When NOT to Add an Index
- Small tables (\\< 1000 rows) — sequential scan is faster
- Columns with very low cardinality (boolean, status with 3 values)
- Write-heavy tables where read performance isn't critical
### Composite Index Order
Put the most selective column first:
```sql
-- Good: TenantId first (narrows the search), then status
CREATE INDEX IX_Courses_Tenant_Status ON Courses (TenantId, Status);
-- Query benefits from this index:
SELECT * FROM Courses WHERE TenantId = @tid AND Status = 'active';
```
## Query Performance
1. **Use `EXPLAIN ANALYZE`** to verify query plans before deploying
2. **Avoid `SELECT *`** — project only the columns you need
3. **Use pagination** — never return unbounded result sets
4. **Avoid N+1 queries** — use `JOIN`, `Include()`, or batch loading
5. **Use `AsNoTracking()`** for read-only queries in EF Core
6. **Connection pooling** — always use a connection pool (default in EF Core)
## Backups
- **Automated daily backups** with point-in-time recovery
- **Test restore** at least monthly — untested backups are not backups
- **Retain** 7 daily + 4 weekly + 3 monthly backups
- **Store offsite** — backups on the same server as the DB are useless if the server dies
## Seed Data
- Use seed data for development only (test accounts, sample data)
- Never seed production databases with test data
- Store seed scripts in `Infrastructure/Persistence/Seeds/`
- Seed data should be idempotent (safe to run multiple times)
---
# Multi-Tenancy
> Multi-tenant architecture patterns and data isolation.
Source: https://devplaybook.tansuasici.com/docs/architecture/multi-tenancy
## When to Use
Any B2B SaaS where each customer (organization, university, company) needs isolated data.
## Strategy: Row-Level Security (RLS)
Single database, shared schema, `TenantId` column on every table.
### Why RLS over separate databases?
| Approach | Pros | Cons |
| ------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------ |
| **Separate DB per tenant** | Full isolation, easy backup/restore per tenant | Expensive, hard to manage migrations, connection pooling nightmare |
| **Separate schema per tenant** | Good isolation, same DB | Migration complexity, dynamic schema routing |
| **RLS (shared schema)** | Simple, cost-effective, easy migrations | Must be rigorous about filtering, noisy neighbor risk |
For most projects, **RLS is the right default**. Switch to separate DBs only when regulatory requirements demand it.
## Implementation Checklist
### 1. Every Entity Has TenantId
```csharp
public abstract class BaseEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
```
**No exceptions.** If an entity doesn't have `TenantId`, it's a bug.
### 2. Tenant Resolution Middleware
Resolve tenant from the request before any data access:
```text
Request → TenantMiddleware → Resolve TenantId → Set in context → Continue
```
Resolution strategies (pick one or combine):
- **Subdomain**: `acme.app.com` → TenantId for Acme
- **Header**: `X-Tenant-Id: `
- **JWT claim**: `tenant_id` in the access token
- **Path**: `/api/tenants/{id}/...` (less clean)
### 3. Global Query Filter
In EF Core, apply a global filter so every query is automatically scoped:
```csharp
modelBuilder.Entity()
.HasQueryFilter(e => e.TenantId == _currentTenant.Id);
```
This is your safety net — even if a developer forgets to filter, the global filter catches it.
### 4. Tenant Scoping in Every Layer
| Layer | How |
| --------------------- | -------------------------------------------------------------------------------- |
| **API** | Middleware resolves tenant, sets in scoped service |
| **Application** | Handlers receive tenant from DI, pass to repositories |
| **Infrastructure** | EF global query filter + explicit checks on write |
| **External services** | Prefix keys/buckets with tenant (Redis: `tenant:{id}:...`, MinIO: `tenant-{id}`) |
### 5. Cross-Tenant Prevention
- **Never** expose an API that lists data across tenants (except super-admin)
- **Always** validate that the resource belongs to the current tenant on update/delete
- **Audit** cross-tenant access attempts (log as security event)
## External Service Isolation
| Service | Isolation Strategy |
| ---------- | ------------------------------------------------------------------ |
| PostgreSQL | Global query filter with TenantId |
| Redis | Key prefix: `tenant:{tenantId}:cache:...` |
| MinIO/S3 | Bucket per tenant: `tenant-{tenantId}` |
| Qdrant | Collection per course (already tenant-scoped via course ownership) |
| ClickHouse | TenantId column in every event table |
## Testing Multi-Tenancy
Critical tests to write:
1. **Tenant A cannot see Tenant B's data** — Create data for two tenants, query as each, verify isolation
2. **Missing TenantId fails** — Requests without tenant context should be rejected (401/403)
3. **Write operations validate tenant** — Updating a resource from another tenant should fail
4. **Global filter works** — Raw query returns all data, but EF query returns only current tenant's data
---
# Security Architecture
> Security guidelines, threat modeling, and best practices.
Source: https://devplaybook.tansuasici.com/docs/architecture/security
## Authentication
### JWT Strategy
- **Access token**: Short-lived (15 minutes)
- **Refresh token**: Long-lived (7 days), stored in HttpOnly cookie
- **Never** store tokens in localStorage (XSS vulnerable)
- Include `tenantId` and `role` in token claims
### Password Policy
- Minimum 8 characters
- At least one uppercase, one lowercase, one digit
- Use bcrypt or Argon2 for hashing (never MD5, never SHA without salt)
- Rate limit login attempts (5 attempts per 15 minutes per IP)
## Authorization
- Role-based access control (RBAC) as the baseline
- Validate permissions at the API layer (middleware or attribute-based)
- Never trust client-side role checks alone — always verify server-side
- Tenant isolation is an authorization boundary — crossing it is a security incident
## Input Validation
### API Layer
- Validate ALL input at the boundary (controllers/route handlers)
- Use schema validation (FluentValidation, Zod, Pydantic)
- Reject invalid input early — don't let it reach business logic
### Common Attacks to Prevent
| Attack | Prevention |
| ------------------- | ------------------------------------------------------------------------------------------ |
| **SQL Injection** | Parameterized queries (EF Core, prepared statements). Never string-concatenate SQL. |
| **XSS** | Sanitize user-generated content. Use framework defaults (React auto-escapes). CSP headers. |
| **CSRF** | Anti-forgery tokens for form submissions. SameSite cookies. |
| **File Upload** | Validate MIME type AND extension. Size limits. Scan for malware. Store outside webroot. |
| **Mass Assignment** | Use DTOs, never bind directly to entities. Whitelist properties. |
| **Path Traversal** | Never use user input in file paths. Validate and sanitize. |
## API Security
### Headers
```text
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'
X-XSS-Protection: 0 (rely on CSP instead)
```
### CORS
- Whitelist only known origins (tenant subdomains)
- Never use `Access-Control-Allow-Origin: *` in production
- Be specific about allowed methods and headers
### Rate Limiting
- Per-tenant and per-user limits
- Stricter limits on auth endpoints
- Return `429 Too Many Requests` with `Retry-After` header
## Secrets Management
- **Never** commit secrets to git (`.env`, API keys, connection strings)
- Use `.env.example` with placeholder values
- Production: environment variables or secrets manager (Azure Key Vault, AWS Secrets Manager)
- Rotate secrets regularly, especially after team member departures
### .gitignore Must Include
```text
.env
.env.local
.env.production
*.pem
*.key
credentials.json
appsettings.Development.json (if it contains real secrets)
```
## Logging
- **Never log**: passwords, tokens, credit card numbers, personal health info
- **OK to log**: user IDs, request paths, response status codes, latency, error types
- Use structured logging (key-value pairs, not interpolated strings)
- Log security events: failed logins, permission denials, cross-tenant access attempts
## Data Privacy (GDPR/KVKK)
- All personal data must be deletable (right to erasure)
- Document what personal data you collect and why
- Provide data export functionality (right to portability)
- Encrypt personal data at rest
- Data retention policies: define how long you keep data and auto-delete after
## Dependency Security
- Run `npm audit` / `dotnet list package --vulnerable` in CI
- Update dependencies with known vulnerabilities promptly
- Pin versions to avoid supply-chain attacks via version hijacking
- Review new dependencies before adding — prefer well-maintained, widely-used packages
---
# Naming Conventions
> Consistent naming across code, files, and infrastructure.
Source: https://devplaybook.tansuasici.com/docs/conventions/naming
## Universal Rules
1. **English only** — All code, variables, functions, files, commits, branches, PR titles: English
2. **Be descriptive** — `getUserById` not `getUser`, `isEmailVerified` not `flag`
3. **Be consistent** — Pick one term and stick with it. Don't mix `user`/`account`/`member` for the same concept.
4. **No abbreviations** — `repository` not `repo`, `configuration` not `config`. Exception: universally understood ones (`id`, `url`, `api`, `dto`, `db`)
## By Language
| Element | .NET (C#) | TypeScript/JS | Python |
| -------------- | ------------- | --------------------- | --------------------- |
| Files | PascalCase.cs | kebab-case.ts | snake\_case.py |
| Classes | PascalCase | PascalCase | PascalCase |
| Interfaces | IPascalCase | PascalCase | PascalCase (Protocol) |
| Functions | PascalCase | camelCase | snake\_case |
| Variables | camelCase | camelCase | snake\_case |
| Constants | PascalCase | SCREAMING\_SNAKE | SCREAMING\_SNAKE |
| Private fields | \_camelCase | \_camelCase or #field | \_snake\_case |
| Enums | PascalCase | PascalCase | PascalCase |
## Git
| Element | Convention | Example |
| -------- | --------------------------- | -------------------------------- |
| Branch | type/kebab-case | `feat/user-authentication` |
| Commit | type: lowercase description | `feat: add user login` |
| Tag | semver | `v1.0.0` |
| PR title | imperative, \\< 70 chars | `Add course enrollment capacity` |
## Project Structure
| Element | Convention | Example |
| ------------- | -------------------------- | -------------------------------------------------- |
| Directories | kebab-case | `user-management/` |
| Config files | kebab-case or dot-notation | `.env`, `docker-compose.yml` |
| Documentation | kebab-case.md | `api-reference.md` |
| Test files | Match source + test suffix | `course-service.test.ts`, `test_course_service.py` |
## Database
| Element | Convention | Example |
| ------------ | ---------------------- | ------------------------------ |
| Tables | PascalCase (plural) | `Courses`, `EnrollmentRecords` |
| Columns | PascalCase | `CreatedAt`, `TenantId` |
| Foreign keys | FK\_Child\_Parent | `FK_Enrollment_Course` |
| Indexes | IX\_Table\_Column | `IX_Courses_TenantId` |
| Migrations | Timestamp\_Description | `20250310_AddCourseTable` |
## API Endpoints
```text
GET /api/courses ← List
GET /api/courses/{id} ← Get by ID
POST /api/courses ← Create
PUT /api/courses/{id} ← Full update
PATCH /api/courses/{id} ← Partial update
DELETE /api/courses/{id} ← Delete
# Nested resources
GET /api/courses/{id}/students
POST /api/courses/{id}/enroll
```
- Plural nouns for resources
- kebab-case for multi-word: `/api/course-categories`
- No verbs in URLs (except actions): `/api/courses/{id}/enroll` is OK
- Version prefix if needed: `/api/v1/courses`
## Common Naming Patterns
| Concept | Pattern | Example |
| ------------ | --------------------------- | ---------------------------------- |
| Boolean | is/has/can/should prefix | `isActive`, `hasPermission` |
| Collections | plural | `courses`, `studentIds` |
| Handlers | verb + noun + Handler | `CreateCourseHandler` |
| Services | noun + Service | `CourseService` |
| Repositories | noun + Repository | `CourseRepository` |
| DTOs | noun + Dto/Request/Response | `CourseDto`, `CreateCourseRequest` |
| Validators | noun + Validator | `CreateCourseValidator` |
| Middleware | noun + Middleware | `TenantMiddleware` |
---
# API Design
> RESTful API design principles and standards.
Source: https://devplaybook.tansuasici.com/docs/conventions/api-design
## URL Structure
```text
https://api.example.com/api/{resource}
```
- **Plural nouns** for resources: `/courses`, `/users`, `/enrollments`
- **kebab-case** for multi-word: `/course-categories`, `/audit-logs`
- **No verbs** in URLs — use HTTP methods instead
- **Nesting** for relationships: `/courses/{id}/students`
- **Max 2 levels** of nesting. Beyond that, use query params or top-level resource.
## HTTP Methods
| Method | Purpose | Idempotent | Request Body | Response |
| -------- | ---------------- | ---------- | ------------- | ---------------------- |
| `GET` | Read resource(s) | Yes | No | 200 + data |
| `POST` | Create resource | No | Yes | 201 + created resource |
| `PUT` | Full update | Yes | Yes | 200 + updated resource |
| `PATCH` | Partial update | Yes | Yes (partial) | 200 + updated resource |
| `DELETE` | Remove resource | Yes | No | 204 (no content) |
## Response Format
### Success (single resource)
```json
{
"id": "abc-123",
"title": "Introduction to Psychology",
"createdAt": "2025-03-10T14:30:00Z"
}
```
### Success (collection)
```json
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalPages": 5,
"totalCount": 98
}
}
```
### Error
See [api-error-format.md](/docs/conventions/api-error-format) for the standard error format.
## Pagination
**Offset-based** (default for most use cases):
```text
GET /api/courses?page=2&pageSize=20
```
| Parameter | Default | Max | Description |
| ---------- | ------- | --- | --------------------- |
| `page` | 1 | — | Page number (1-based) |
| `pageSize` | 20 | 100 | Items per page |
Response includes pagination metadata in the response body (see above).
**Cursor-based** (for real-time feeds, infinite scroll):
```text
GET /api/notifications?cursor=eyJpZCI6MTIzfQ&limit=20
```
Use cursor-based when:
- Data changes frequently (new items inserted)
- You need consistent pagination without duplicates
- Large datasets where OFFSET is slow
## Filtering
Use query parameters:
```text
GET /api/courses?status=active&departmentId=5&search=psychology
```
- **Exact match**: `?status=active`
- **Search/partial**: `?search=psych` (searches relevant text fields)
- **Date range**: `?createdAfter=2025-01-01&createdBefore=2025-12-31`
- **Multiple values**: `?status=active,archived` (comma-separated)
## Sorting
```text
GET /api/courses?sortBy=createdAt&sortOrder=desc
```
| Parameter | Values | Default |
| ----------- | ------------- | ----------- |
| `sortBy` | Field name | `createdAt` |
| `sortOrder` | `asc`, `desc` | `desc` |
For multiple sort fields: `?sortBy=status,-createdAt` (prefix `-` for descending).
## Versioning
Prefer **URL path versioning** when breaking changes are needed:
```text
/api/v1/courses
/api/v2/courses
```
Rules:
- Don't version from day one — start with `/api/courses`
- Add versioning only when you make a breaking change
- Support the previous version for a deprecation period
- Document the migration path
## Rate Limiting
Return these headers on every response:
```text
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1620000000
```
When exceeded, return `429 Too Many Requests` with `Retry-After` header.
## Common Patterns
### Bulk Operations
```text
POST /api/courses/bulk-delete
Body: { "ids": ["abc", "def", "ghi"] }
```
### Actions (non-CRUD operations)
```text
POST /api/courses/{id}/publish
POST /api/courses/{id}/archive
POST /api/users/{id}/deactivate
```
### Health Check
```text
GET /api/health → 200 { "status": "healthy" }
GET /api/health/ready → 200 or 503 (checks dependencies)
```
## Response Codes Cheat Sheet
| Code | When |
| --------------------------- | -------------------------------------------------- |
| `200 OK` | Successful GET, PUT, PATCH |
| `201 Created` | Successful POST (include `Location` header) |
| `204 No Content` | Successful DELETE |
| `400 Bad Request` | Validation error, malformed request |
| `401 Unauthorized` | Missing or invalid authentication |
| `403 Forbidden` | Authenticated but not authorized |
| `404 Not Found` | Resource doesn't exist |
| `409 Conflict` | Business rule violation (duplicate, capacity full) |
| `422 Unprocessable Entity` | Valid JSON but semantically wrong |
| `429 Too Many Requests` | Rate limit exceeded |
| `500 Internal Server Error` | Unhandled server error (never expose details) |
---
# API Error Format
> Standardized error response format for APIs.
Source: https://devplaybook.tansuasici.com/docs/conventions/api-error-format
## Standard: RFC 7807 (Problem Details)
Use a consistent error format across all APIs, based on [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807).
## Error Response Shape
```json
{
"type": "https://docs.example.com/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "One or more fields failed validation.",
"instance": "/api/courses",
"traceId": "00-abc123-def456-01",
"errors": {
"title": ["Title is required.", "Title must be under 200 characters."],
"maxCapacity": ["Max capacity must be a positive number."]
}
}
```
## Field Definitions
| Field | Required | Description |
| ---------- | -------- | ---------------------------------------------------------------------------- |
| `type` | Yes | URI identifying the error type (link to docs) |
| `title` | Yes | Human-readable summary (same for all instances of this type) |
| `status` | Yes | HTTP status code |
| `detail` | Yes | Human-readable explanation specific to this occurrence |
| `instance` | No | The request path that caused the error |
| `traceId` | No | Correlation ID for debugging |
| `errors` | No | Field-level validation errors (key: field name, value: error messages array) |
## Error Types by Status Code
### 400 Bad Request — Validation Error
```json
{
"type": "https://docs.example.com/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "One or more fields failed validation.",
"errors": {
"email": ["Email is required."],
"password": ["Password must be at least 8 characters."]
}
}
```
### 401 Unauthorized — Authentication Required
```json
{
"type": "https://docs.example.com/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication is required to access this resource."
}
```
### 403 Forbidden — Insufficient Permissions
```json
{
"type": "https://docs.example.com/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to delete this course."
}
```
### 404 Not Found
```json
{
"type": "https://docs.example.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Course with ID 'abc-123' was not found."
}
```
### 409 Conflict — Business Rule Violation
```json
{
"type": "https://docs.example.com/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "Course has reached maximum enrollment capacity."
}
```
### 429 Too Many Requests
```json
{
"type": "https://docs.example.com/errors/rate-limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Try again in 60 seconds."
}
```
### 500 Internal Server Error
```json
{
"type": "https://docs.example.com/errors/internal-error",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred. Please try again later."
}
```
**Never expose stack traces, exception messages, or internal details in 500 responses.**
## Implementation
### .NET (ASP.NET Core)
ASP.NET Core has built-in Problem Details support:
```csharp
builder.Services.AddProblemDetails();
// In exception handler middleware
app.UseExceptionHandler(appBuilder =>
{
appBuilder.Run(async context =>
{
var problemDetails = new ProblemDetails
{
Status = 500,
Title = "Internal Server Error",
Detail = "An unexpected error occurred."
};
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
```
### Next.js (API Routes)
```typescript
function createErrorResponse(status: number, title: string, detail: string, errors?: Record) {
return Response.json({
type: `https://docs.example.com/errors/${title.toLowerCase().replace(/\s/g, '-')}`,
title,
status,
detail,
...(errors && { errors }),
}, { status });
}
```
## Rules
1. **Always return JSON** — Even for errors. No HTML error pages from APIs.
2. **Always include `status`** — Even though it's in the HTTP status code.
3. **Keep `detail` user-friendly** — No stack traces, no internal class names.
4. **Use `errors` object for validation** — One key per field, array of messages per key.
5. **Consistent across all endpoints** — Same shape everywhere. No surprises.
6. **Log the real error server-side** — The client gets a safe message; the server logs the full exception.
---
# Error Handling
> Exception handling patterns and error management strategies.
Source: https://devplaybook.tansuasici.com/docs/conventions/error-handling
> See also: [api-error-format.md](/docs/conventions/api-error-format) for the standard API error response format (RFC 7807).
## Core Principle
**Handle errors at the right level.** Don't catch exceptions you can't meaningfully handle. Let them bubble up to a global handler.
## Error Categories
| Category | Example | Response Code | Handling |
| -------------------- | ---------------------------- | ------------- | -------------------------------------- |
| **Validation** | Invalid email, missing field | 400 | Return specific field errors |
| **Authentication** | Missing/expired token | 401 | Return generic "unauthorized" |
| **Authorization** | Wrong role, wrong tenant | 403 | Return generic "forbidden" |
| **Not Found** | Resource doesn't exist | 404 | Return "not found" |
| **Business Rule** | Course full, duplicate title | 409 / 422 | Return specific business error |
| **External Service** | API timeout, service down | 502 / 503 | Retry or degrade gracefully |
| **Internal** | Unhandled exception, bug | 500 | Log full details, return generic error |
## Architecture
```text
Controller / Route Handler
↓ catches validation errors → 400
↓
Service / Use Case
↓ throws business exceptions → 409/422
↓
Repository / External Call
↓ throws infrastructure exceptions → 502/503
↓
Global Exception Handler
↓ catches everything else → 500 (logged, generic response)
```
## Implementation
### .NET — Exception Middleware
```csharp
// Custom exception types
public class NotFoundException : Exception
{
public NotFoundException(string entity, object id)
: base($"{entity} with id '{id}' was not found.") { }
}
public class BusinessRuleException : Exception
{
public BusinessRuleException(string message) : base(message) { }
}
public class ConflictException : Exception
{
public ConflictException(string message) : base(message) { }
}
// Global exception handler middleware
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (NotFoundException ex)
{
context.Response.StatusCode = 404;
await WriteProblemDetails(context, "Not Found", ex.Message, 404);
}
catch (BusinessRuleException ex)
{
context.Response.StatusCode = 422;
await WriteProblemDetails(context, "Business Rule Violation", ex.Message, 422);
}
catch (ConflictException ex)
{
context.Response.StatusCode = 409;
await WriteProblemDetails(context, "Conflict", ex.Message, 409);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception for {RequestPath}", context.Request.Path);
context.Response.StatusCode = 500;
await WriteProblemDetails(context, "Internal Server Error",
"An unexpected error occurred.", 500);
}
}
}
```
### TypeScript — Error Classes
```typescript
// Custom error classes
class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
) {
super(message);
}
}
class NotFoundError extends AppError {
constructor(entity: string, id: string) {
super(404, 'NOT_FOUND', `${entity} with id '${id}' not found`);
}
}
class BusinessRuleError extends AppError {
constructor(message: string) {
super(422, 'BUSINESS_RULE_VIOLATION', message);
}
}
class ConflictError extends AppError {
constructor(message: string) {
super(409, 'CONFLICT', message);
}
}
// Global error handler (Express / Next.js API route wrapper)
function withErrorHandler(handler: Function) {
return async (req: Request, res: Response) => {
try {
await handler(req, res);
} catch (error) {
if (error instanceof AppError) {
res.status(error.statusCode).json({
type: `https://docs.api.com/errors/${error.code.toLowerCase()}`,
title: error.code,
status: error.statusCode,
detail: error.message,
});
} else {
logger.error('Unhandled error', { error, path: req.url });
res.status(500).json({
type: 'https://docs.api.com/errors/internal',
title: 'Internal Server Error',
status: 500,
detail: 'An unexpected error occurred.',
});
}
}
};
}
```
### Python — Exception Handlers
```python
# Custom exceptions
class AppException(Exception):
def __init__(self, status_code: int, code: str, detail: str):
self.status_code = status_code
self.code = code
self.detail = detail
class NotFoundException(AppException):
def __init__(self, entity: str, id: str):
super().__init__(404, "NOT_FOUND", f"{entity} with id '{id}' not found")
class BusinessRuleException(AppException):
def __init__(self, detail: str):
super().__init__(422, "BUSINESS_RULE_VIOLATION", detail)
# FastAPI exception handler
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
return JSONResponse(
status_code=exc.status_code,
content={
"type": f"https://docs.api.com/errors/{exc.code.lower()}",
"title": exc.code,
"status": exc.status_code,
"detail": exc.detail,
},
)
```
## External Service Errors
### Retry Strategy
```text
Retry 1 → wait 1s
Retry 2 → wait 2s
Retry 3 → wait 4s
Give up → return 503 or fallback
```
```csharp
// .NET — Polly retry policy
builder.Services.AddHttpClient("ExternalApi")
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))));
```
### Circuit Breaker
When an external service is consistently failing, stop calling it temporarily:
```text
Closed (normal) → Too many failures → Open (reject calls for 30s) → Half-Open (try one) → Closed
```
### Graceful Degradation
- If the recommendation service is down, show default content instead of an error
- If the email service is down, queue the email for later delivery
- If the search service is down, fall back to database query
## Rules
1. **Never swallow exceptions silently** — catch only if you handle it meaningfully
2. **Never expose internal details** in API responses — stack traces, SQL queries, file paths
3. **Log at the boundary** — global handler logs unhandled exceptions, not every catch block
4. **Use typed exceptions** — `NotFoundException`, not `throw new Exception("not found")`
5. **Fail fast on startup** — missing config, unreachable database = crash, don't limp along
6. **Return consistent error format** — always RFC 7807 Problem Details (see [api-error-format.md](/docs/conventions/api-error-format))
7. **Don't use exceptions for flow control** — check `if (course == null)` instead of catching `NullReferenceException`
## Anti-Patterns
```csharp
// DON'T: Pokemon exception handling (catch 'em all)
try { ... }
catch (Exception) { return null; } // What went wrong? Nobody knows.
// DON'T: Log and throw (duplicate logging)
catch (Exception ex)
{
_logger.LogError(ex, "Failed");
throw; // Global handler will log AGAIN
}
// DON'T: Expose internals
catch (NpgsqlException ex)
{
return BadRequest(ex.Message); // Leaks DB schema info
}
// DON'T: Empty catch
catch (Exception) { } // Silently swallowed. Debugging nightmare.
// DO: Handle specifically or let it bubble
catch (DuplicateKeyException)
{
throw new ConflictException("A course with this title already exists");
}
```
---
# Logging
> Structured logging standards and best practices.
Source: https://devplaybook.tansuasici.com/docs/conventions/logging
## Log Levels
| Level | When to Use | Example |
| ----------------- | --------------------------------- | -------------------------------------------------------------- |
| **Fatal** | Application cannot continue | DB connection permanently lost, corrupt config |
| **Error** | Operation failed, needs attention | Unhandled exception, external service down |
| **Warning** | Something unexpected but handled | Retry succeeded, deprecated API called, rate limit approaching |
| **Information** | Normal business events | User logged in, course created, payment processed |
| **Debug** | Diagnostic detail for development | Request/response payloads, cache hit/miss, query timing |
| **Verbose/Trace** | Extremely detailed, noisy | Method entry/exit, variable values (dev only) |
### Production Minimum Level: `Information`
Debug and Verbose should never be enabled in production by default. Use dynamic log level switching for temporary debugging.
## Structured Logging
**Always use structured logging** — key-value pairs, not string interpolation.
```csharp
// GOOD — structured, searchable
_logger.LogInformation("Course created {CourseId} by {UserId} in tenant {TenantId}",
courseId, userId, tenantId);
// BAD — string interpolation, not searchable
_logger.LogInformation($"Course created {courseId} by {userId}");
```
```typescript
// GOOD
logger.info('Course created', { courseId, userId, tenantId });
// BAD
logger.info(`Course created ${courseId} by ${userId}`);
```
```python
# GOOD
logger.info("Course created", extra={"course_id": course_id, "user_id": user_id})
# BAD
logger.info(f"Course created {course_id} by {user_id}")
```
## What to Log
### Always Log
- **Authentication events**: login success/failure, token refresh, logout
- **Authorization failures**: forbidden access attempts
- **Business events**: resource created/updated/deleted, state transitions
- **External service calls**: request sent, response received, errors (with latency)
- **Background job lifecycle**: started, completed, failed
- **Application startup/shutdown**: config loaded, services registered, graceful shutdown
### Never Log
- Passwords or password hashes
- Access tokens, refresh tokens, API keys
- Credit card numbers or financial details
- Personal health information
- Full request/response bodies in production (use Debug level only)
- Personal data beyond what's needed (email OK for auth events, not for every log)
### Log with Caution
- Email addresses — OK in auth context, mask elsewhere
- User IDs — OK, they're not PII by themselves
- IP addresses — may be PII under GDPR, log only for security events
- Request bodies — Debug level only, redact sensitive fields
## Context Fields
Include these fields consistently in every log entry:
| Field | Purpose | Example |
| --------------- | ------------------------ | ----------------- |
| `TenantId` | Multi-tenant isolation | `"tenant-abc123"` |
| `UserId` | Who triggered the action | `"user-456"` |
| `CorrelationId` | Trace across services | `"req-789xyz"` |
| `RequestPath` | Which endpoint | `"/api/courses"` |
| `Duration` | How long it took | `145` (ms) |
Use middleware or logging enrichers to add these automatically — don't manually pass them everywhere.
## Log Aggregation
- **Development**: Console output (readable, colored)
- **Staging/Production**: Centralized log aggregation (Seq, ELK, Grafana Loki)
- **Retention**: 30 days for Info+, 7 days for Debug (if enabled)
- **Alerts**: Set up alerts for Error and Fatal level spikes
## Anti-Patterns
```csharp
// DON'T: Log and throw — produces duplicate noise
try { ... }
catch (Exception ex)
{
_logger.LogError(ex, "Something failed");
throw; // The global handler will log this AGAIN
}
// DO: Log at the boundary (global exception handler) or at the catch, not both.
// DON'T: Empty catch with log
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred");
// and then what? Swallowed silently.
}
// DON'T: Log every method entry/exit in production
_logger.LogInformation("Entering GetCourseById"); // Noise
_logger.LogInformation("Exiting GetCourseById"); // Noise
// DO: Log meaningful events with context
_logger.LogInformation("Course retrieved {CourseId} in {Duration}ms", courseId, elapsed);
```
---
# Observability
> Metrics, tracing, alerting, and health check standards.
Source: https://devplaybook.tansuasici.com/docs/conventions/observability
Logging alone is not enough. A production system needs **metrics**, **tracing**, and **alerting** to be truly observable.
> See also: [logging.md](/docs/conventions/logging) for structured logging standards.
## Three Pillars
| Pillar | What It Tells You | Tools |
| ----------- | ------------------------------------------------- | ----------------------------- |
| **Logs** | What happened (discrete events) | Seq, ELK, Grafana Loki |
| **Metrics** | How the system is performing (aggregated numbers) | Prometheus, Grafana, InfluxDB |
| **Traces** | How a request flows across services (distributed) | Jaeger, Zipkin, OpenTelemetry |
## Health Checks
Every service must expose health endpoints.
### Endpoints
```text
GET /api/health → 200 { "status": "healthy" }
GET /api/health/ready → 200 or 503 (checks dependencies)
GET /api/health/live → 200 (process is running, used by k8s liveness probe)
```
### What Readiness Checks
| Dependency | Check |
| ------------- | ------------------------------------- |
| Database | Can connect and run a simple query |
| Redis/Cache | Can connect and ping |
| External API | Can reach the endpoint (with timeout) |
| Message Queue | Can connect to broker |
| File Storage | Can list/read from bucket |
```csharp
// .NET
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString, name: "postgres")
.AddRedis(redisConnection, name: "redis")
.AddUrlGroup(new Uri("https://external-api/health"), name: "external-api");
```
```typescript
// Next.js / Express
app.get('/api/health/ready', async (req, res) => {
const checks = {
database: await checkDatabase(),
redis: await checkRedis(),
storage: await checkStorage(),
};
const healthy = Object.values(checks).every(Boolean);
res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'unhealthy', checks });
});
```
## Metrics
### What to Measure
| Metric | Type | Example |
| ---------------------- | --------- | -------------------------------------------- |
| **Request rate** | Counter | `http_requests_total` |
| **Request duration** | Histogram | `http_request_duration_seconds` |
| **Error rate** | Counter | `http_errors_total` (by status code) |
| **Active connections** | Gauge | `http_active_connections` |
| **Queue depth** | Gauge | `job_queue_size` |
| **Business metrics** | Counter | `enrollments_total`, `courses_created_total` |
### RED Method (for services)
Every service should track:
- **R**ate — requests per second
- **E**rrors — failed requests per second
- **D**uration — time per request (p50, p95, p99)
### USE Method (for resources)
Every resource (CPU, memory, disk, connections) should track:
- **U**tilization — percentage in use
- **S**aturation — queue depth / backlog
- **E**rrors — error count
### Naming Convention
```text
__
# Examples
http_request_duration_seconds
db_query_duration_seconds
cache_hits_total
job_processing_errors_total
```
## Distributed Tracing
### When You Need It
- Multiple services handle a single request
- You need to find where latency comes from
- You need to debug cross-service failures
### Implementation
Use **OpenTelemetry** as the standard — it's vendor-neutral and supports all major backends.
```csharp
// .NET
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter());
```
```python
# Python
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)
```
### Propagation
- Pass `traceparent` header between services (W3C Trace Context standard)
- Include `CorrelationId` in all log entries for manual correlation
- Never create custom trace ID formats — use the standard
## Alerting
### Alert Rules
| Severity | Condition | Action |
| ------------ | --------------------------------------------------- | ------------------------------------------ |
| **Critical** | Service down, error rate \\> 50%, data loss risk | Page on-call immediately |
| **Warning** | Error rate \\> 5%, latency p99 \\> 2s, disk \\> 80% | Notify in Slack, investigate within 1 hour |
| **Info** | Deployment completed, cert expiring in 30 days | Log for awareness |
### Alert Best Practices
- **Alert on symptoms, not causes** — "API latency \\> 2s" not "CPU \\> 80%"
- **Include runbook links** in alert messages
- **Set appropriate thresholds** — avoid alert fatigue from noisy alerts
- **Use alert grouping** — don't fire 100 alerts for the same incident
- **Test alerts** — regularly verify they fire when expected
### Alert Template
```yaml
alert: HighErrorRate
expr: rate(http_errors_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate on {{ $labels.service }}"
description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes"
runbook: "https://wiki.internal/runbooks/high-error-rate"
```
## Dashboards
### Every Service Dashboard Should Have
1. **Request rate** — traffic over time
2. **Error rate** — 4xx and 5xx separately
3. **Latency** — p50, p95, p99 over time
4. **Saturation** — CPU, memory, connections, queue depth
5. **Business metrics** — relevant domain-specific counters
### Dashboard Rules
- One dashboard per service, not one giant dashboard
- Use consistent time ranges and refresh intervals
- Include links to related dashboards and runbooks
- Mark deployment events on graphs for correlation
## Anti-Patterns
- **Logging as the only observability** — Logs alone can't show trends or distributions
- **No health checks** — You can't know if a service is healthy without asking it
- **Alerting on every metric** — Alert fatigue leads to ignored alerts
- **Custom metrics libraries** — Use OpenTelemetry, don't reinvent the wheel
- **Dashboard without context** — Every graph should have a title that explains what "bad" looks like
---
# Testing
> Testing strategies, coverage goals, and test patterns.
Source: https://devplaybook.tansuasici.com/docs/conventions/testing
## Core Principle
**Test behavior, not implementation.** Tests should verify what the code does, not how it does it. If you refactor internals and tests break, the tests were wrong.
## Test Pyramid
```text
/ E2E \ ← Few: critical user flows only
/ Integr. \ ← Some: API endpoints, DB queries, external services
/ Unit \ ← Many: business logic, pure functions, handlers
```
| Level | What to Test | Tools | Speed |
| --------------- | -------------------------------------------------------------- | --------------------------- | ---------------------- |
| **Unit** | Business logic, handlers, services, validators, pure functions | xUnit/Vitest/pytest + mocks | Fast (ms) |
| **Integration** | API endpoints, DB operations, external service calls | TestContainers, httpx | Medium (seconds) |
| **E2E** | Critical user flows through the UI | Playwright | Slow (seconds-minutes) |
## What to Test
### Always Test
- Business logic and domain rules
- Validation logic (valid and invalid inputs)
- Edge cases (null, empty, boundary values, max limits)
- Error handling paths
- Authorization rules (can this role do this?)
- Data transformations (mapping, serialization)
### Don't Bother Testing
- Framework boilerplate (DI registration, middleware wiring)
- Simple CRUD with no business logic
- Third-party library internals
- Private methods directly (test them through public API)
- Getters/setters with no logic
## Test Naming
**Pattern**: `MethodName_Scenario_ExpectedResult`
```csharp
// C#
CreateCourse_WithValidData_ReturnsCourseId()
CreateCourse_WithDuplicateTitle_ThrowsConflictException()
EnrollStudent_WhenCourseIsFull_Returns409()
```
```typescript
// TypeScript
describe('CourseService', () => {
it('creates a course with valid data', () => {})
it('throws when course title is duplicate', () => {})
it('returns 409 when course is at capacity', () => {})
})
```
```python
# Python
def test_create_course_with_valid_data_returns_course_id():
def test_create_course_with_duplicate_title_raises_conflict():
def test_enroll_student_when_full_returns_409():
```
## Test Structure: Arrange-Act-Assert
Every test follows this pattern:
```csharp
[Fact]
public async Task EnrollStudent_WhenCourseIsFull_Returns409()
{
// Arrange — set up the scenario
var course = CreateCourse(maxCapacity: 1);
await EnrollStudent(course.Id, studentId: "first-student");
// Act — execute the thing being tested
var result = await _handler.Handle(new EnrollStudentCommand(course.Id, "second-student"));
// Assert — verify the outcome
result.StatusCode.Should().Be(409);
}
```
Keep each section short. If Arrange is 20 lines, extract a helper or use a builder/factory.
## Mocking Rules
1. **Mock at boundaries** — external services, databases, file system, clock
2. **Don't mock what you own** — if you wrote the class, use the real thing (or an in-memory fake)
3. **Don't mock value objects** — just construct them
4. **One mock per test** (ideally) — if you need 5 mocks, the code has too many dependencies
5. **Integration tests use real infra** — TestContainers for DB, real Redis, etc.
## Test Data
- **Use builders or factories** for complex objects, not raw constructors everywhere
- **Don't share mutable state** between tests — each test sets up its own data
- **Use meaningful test data** — `"john@example.com"` not `"test123"`
- **Avoid magic numbers** — use named constants or clearly labeled variables
## Coverage
- **Don't chase 100%** — diminishing returns after \~80%
- **Cover the critical paths**: auth, payments, data mutations, business rules
- **Uncovered code is a signal**, not a crime — investigate, don't blindly add tests
- **CI should report coverage** but not block on a threshold (quality \\> quantity)
## When to Write Tests
- **Before fixing a bug**: Write a failing test that reproduces the bug, then fix it
- **During feature development**: Write tests as you implement, not after
- **Not retroactively for old code**: Unless you're modifying it — then add tests for what you touch
## Flaky Tests
Flaky tests (pass sometimes, fail sometimes) are worse than no tests:
- **Fix immediately** or delete — don't leave them failing intermittently
- Common causes: timing issues, shared state, network dependencies, date/time
- Never use `continue-on-error` or `|| true` as a "fix" for flaky tests
---
# Performance
> Response time budgets, caching, and optimization patterns.
Source: https://devplaybook.tansuasici.com/docs/conventions/performance
## Response Time Budgets
| Endpoint Type | Target (p95) | Max (p99) |
| ----------------------- | ------------ | ------------------ |
| Simple read (GET by ID) | \\< 100ms | \\< 200ms |
| List with pagination | \\< 200ms | \\< 500ms |
| Search with filters | \\< 300ms | \\< 800ms |
| Write (POST/PUT/PATCH) | \\< 200ms | \\< 500ms |
| File upload | \\< 2s | \\< 5s |
| Report generation | \\< 5s | \\< 10s (or async) |
If an operation exceeds 10 seconds, make it **asynchronous** — return `202 Accepted` and process in the background.
## Database Performance
### N+1 Query Prevention
The most common performance bug. Loading a list then querying each item individually.
```csharp
// BAD — N+1: 1 query for courses + N queries for instructors
var courses = await _context.Courses.ToListAsync();
foreach (var course in courses)
{
course.Instructor = await _context.Users.FindAsync(course.InstructorId);
}
// GOOD — eager loading: 1 query with JOIN
var courses = await _context.Courses
.Include(c => c.Instructor)
.ToListAsync();
```
```typescript
// BAD — N+1
const courses = await prisma.course.findMany();
for (const course of courses) {
course.instructor = await prisma.user.findUnique({ where: { id: course.instructorId } });
}
// GOOD — include relation
const courses = await prisma.course.findMany({
include: { instructor: true },
});
```
```python
# BAD — N+1
courses = session.query(Course).all()
for course in courses:
print(course.instructor.name) # lazy load triggers N queries
# GOOD — eager load
courses = session.query(Course).options(joinedload(Course.instructor)).all()
```
### Query Rules
- **Always paginate** list endpoints — never return unbounded results
- **Select only needed columns** — don't `SELECT *` when you need 3 fields
- **Index WHERE, JOIN, ORDER BY columns** — see [database.md](/docs/architecture/database)
- **Use EXPLAIN ANALYZE** on slow queries to understand the execution plan
- **Set query timeouts** — a runaway query shouldn't take down the database
```csharp
// Select only what you need
var courseSummaries = await _context.Courses
.Where(c => c.TenantId == tenantId && !c.IsDeleted)
.Select(c => new CourseSummaryDto
{
Id = c.Id,
Title = c.Title,
StudentCount = c.Enrollments.Count,
})
.ToListAsync();
```
## Caching Strategy
### Cache Layers
| Layer | What to Cache | TTL | Invalidation |
| --------------- | ----------------------------------- | ----------------------- | ---------------------------- |
| **Browser** | Static assets (JS, CSS, images) | Long (1 year with hash) | New deployment |
| **CDN** | Public pages, images, API responses | Minutes to hours | Purge on update |
| **Application** | DB query results, computed values | Seconds to minutes | Write-through or event-based |
| **Database** | Query plans, prepared statements | Automatic | Database manages this |
### When to Cache
- Data that's read far more than written (course catalog, user profiles)
- Expensive computations (analytics, aggregations, reports)
- External API responses (with appropriate TTL)
### When NOT to Cache
- User-specific sensitive data (unless per-user cache with auth)
- Rapidly changing data (real-time feeds, live counters)
- Data that must be consistent (financial transactions, enrollment counts during registration)
### Implementation
```csharp
// .NET — IDistributedCache with Redis
public async Task GetCourseAsync(Guid courseId)
{
var cacheKey = $"course:{courseId}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
return JsonSerializer.Deserialize(cached);
var course = await _repository.GetByIdAsync(courseId);
if (course == null) return null;
var dto = course.ToDto();
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(dto),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
return dto;
}
// Invalidate on write
public async Task UpdateCourseAsync(Guid courseId, UpdateCourseRequest request)
{
await _repository.UpdateAsync(courseId, request);
await _cache.RemoveAsync($"course:{courseId}");
}
```
```typescript
// TypeScript — Redis cache helper
async function cached(key: string, ttlSeconds: number, fn: () => Promise): Promise {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const result = await fn();
await redis.setex(key, ttlSeconds, JSON.stringify(result));
return result;
}
// Usage
const course = await cached(`course:${id}`, 300, () => db.course.findUnique({ where: { id } }));
```
### Cache Key Convention
```text
: → course:abc-123
:list:: → course:list:tenant-1:page1-size20
:count: → course:count:tenant-1
```
## Frontend Performance
### Core Web Vitals Targets
| Metric | Target | What It Measures |
| ----------------------------------- | --------- | ------------------- |
| **LCP** (Largest Contentful Paint) | \\< 2.5s | Loading performance |
| **INP** (Interaction to Next Paint) | \\< 200ms | Interactivity |
| **CLS** (Cumulative Layout Shift) | \\< 0.1 | Visual stability |
### Rules
- **Lazy load** below-the-fold content and routes
- **Optimize images** — use WebP/AVIF, serve responsive sizes, always set width/height
- **Minimize JavaScript** — code split by route, tree-shake unused imports
- **Prefetch** likely navigation targets
- **Use SSR/SSG** for content pages, CSR for interactive dashboards
```typescript
// Next.js — Dynamic import for heavy components
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => ,
ssr: false,
});
// Image optimization
import Image from 'next/image';
```
## Background Jobs
Move heavy operations out of the request path:
| Operation | Approach |
| --------------------- | ----------------------------------------------- |
| Sending emails | Queue → Background worker |
| Generating reports | Queue → Return job ID → Poll/webhook for result |
| Processing uploads | Queue → Worker → Notify on completion |
| Analytics aggregation | Scheduled job (cron) |
| Cache warming | Scheduled job or event-driven |
```csharp
// .NET — Return 202 and process in background
[HttpPost("reports/generate")]
public async Task GenerateReport([FromBody] ReportRequest request)
{
var jobId = await _backgroundJobs.EnqueueAsync(
x => x.GenerateAsync(request));
return Accepted(new { jobId, statusUrl = $"/api/reports/jobs/{jobId}" });
}
```
## Anti-Patterns
- **No pagination** — Returning 10,000 rows because "it works in dev"
- \*\*SELECT \*\*\* — Fetching 20 columns when you need 3
- **Cache everything** — Caching data that changes every second or is accessed once
- **Premature optimization** — Profile first, optimize the bottleneck, not what you guess
- **Synchronous heavy operations** — Generating PDFs or sending emails in the request handler
- **No query monitoring** — You can't fix what you can't see. Log slow queries (\\> 100ms)
---
# .NET Conventions
> C# and .NET specific coding standards.
Source: https://devplaybook.tansuasici.com/docs/conventions/dotnet
## Architecture: Clean Architecture
```text
Solution/
├── src/
│ ├── Domain/ ← Entities, value objects, enums, domain events
│ ├── Application/ ← Use cases, CQRS handlers, DTOs, interfaces
│ ├── Infrastructure/ ← EF Core, external services, repositories
│ └── Api/ ← Controllers, middleware, DI configuration
└── tests/
├── UnitTests/
└── IntegrationTests/
```
**Dependency rule**: Inner layers never reference outer layers. Domain has zero dependencies.
## Patterns
| Pattern | Tool | Notes |
| --------------- | ------------------------------------- | ----------------------------------- |
| CQRS | MediatR | Separate Commands and Queries |
| Validation | FluentValidation | On every request DTO |
| Object Mapping | Mapster | NOT AutoMapper (commercial license) |
| ORM | EF Core | Repository pattern on top |
| Logging | Serilog | Structured logging to Seq |
| Background Jobs | Hangfire | For long-running tasks |
| Health Checks | AspNetCore.HealthChecks | For every external dependency |
| OpenAPI | Microsoft.AspNetCore.OpenApi + Scalar | NOT Swashbuckle (deprecated) |
## Naming
| Element | Convention | Example |
| -------------- | ---------------- | ---------------------- |
| Public members | PascalCase | `GetCourseById()` |
| Private fields | \_camelCase | `_courseRepository` |
| Parameters | camelCase | `courseId` |
| Interfaces | I-prefix | `ICourseRepository` |
| Async methods | Async suffix | `GetCourseByIdAsync()` |
| Constants | PascalCase | `MaxRetryCount` |
| Files | Match class name | `CourseService.cs` |
## Async Rules
- **Async all the way down** — No `.Result`, no `.Wait()`, no `Task.Run()` for IO
- **Always pass `CancellationToken`** in async method signatures
- **Use `ConfigureAwait(false)`** in library code only, not in ASP.NET controllers
## CQRS Structure
```text
Application/
├── Features/
│ └── Courses/
│ ├── Commands/
│ │ ├── CreateCourse/
│ │ │ ├── CreateCourseCommand.cs
│ │ │ ├── CreateCourseCommandHandler.cs
│ │ │ └── CreateCourseCommandValidator.cs
│ │ └── UpdateCourse/
│ │ └── ...
│ └── Queries/
│ └── GetCourseById/
│ ├── GetCourseByIdQuery.cs
│ ├── GetCourseByIdQueryHandler.cs
│ └── CourseDto.cs
```
## Error Handling
- Use Result pattern or custom exceptions — not bare `try/catch` everywhere
- Domain exceptions: `DomainException`, `NotFoundException`, `ConflictException`
- Global exception handler middleware maps exceptions to HTTP status codes
- Never expose stack traces or internal details in API responses
## EF Core
- Fluent API configuration in separate `EntityTypeConfiguration` classes
- Always use migrations, never `EnsureCreated()`
- Use `AsNoTracking()` for read-only queries
- Avoid lazy loading — use explicit `Include()` or projection
## Testing
- **xUnit** for test framework
- **NSubstitute** or **Moq** for mocking
- **TestContainers** for integration tests (real PostgreSQL, Redis)
- Test the service/handler layer, not the framework
- Naming: `MethodName_StateUnderTest_ExpectedBehavior`
```csharp
[Fact]
public async Task CreateCourse_WithValidData_ReturnsCourseId()
```
---
# Next.js Conventions
> Next.js and React specific patterns and standards.
Source: https://devplaybook.tansuasici.com/docs/conventions/nextjs
## Architecture: App Router
**App Router only** — no `pages/` directory.
```text
src/
├── app/
│ ├── [locale]/
│ │ ├── (portal-student)/
│ │ ├── (portal-instructor)/
│ │ ├── (portal-admin)/
│ │ ├── (auth)/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── api/ ← Route handlers (if needed)
│ └── globals.css
├── components/
│ ├── ui/ ← shadcn/ui primitives
│ ├── shared/ ← Reusable across portals
│ └── features/ ← Feature-specific components
├── lib/
│ ├── api/ ← API client functions
│ ├── hooks/ ← Custom React hooks
│ ├── stores/ ← Zustand stores
│ └── utils/ ← Pure utility functions
├── types/ ← TypeScript type definitions
└── messages/ ← i18n translation files (next-intl)
```
## Component Rules
1. **Server Components by default** — Only add `"use client"` when you need interactivity, browser APIs, or hooks
2. **Colocate** — Keep related files together (component + its types + its styles)
3. **One component per file** — Exception: small helper components used only by the parent
4. **Export named, not default** — `export function CourseCard()` not `export default function`
- *Exception*: App Router route files (`page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`) require default exports per Next.js convention
## State Management
| Type | Tool | When |
| ------------ | --------------------- | --------------------------------------- |
| Server state | TanStack Query | API data fetching, caching, mutations |
| Client state | Zustand | UI state (sidebar open, theme, filters) |
| Form state | React Hook Form + Zod | All forms, always validated |
| URL state | `useSearchParams` | Filters, pagination, sorting |
**Never** use React Context for state that changes frequently. Zustand or URL state instead.
## Naming
| Element | Convention | Example |
| ---------------- | ------------------------------ | ----------------- |
| Files | kebab-case | `course-card.tsx` |
| Components | PascalCase | `CourseCard` |
| Hooks | camelCase with `use` prefix | `useCourseData` |
| Stores | camelCase with `use` + `Store` | `useSidebarStore` |
| Types/Interfaces | PascalCase | `CourseResponse` |
| Constants | SCREAMING\_SNAKE | `MAX_FILE_SIZE` |
| CSS classes | Tailwind utilities | — |
## Styling
- **Tailwind CSS** for everything — no CSS modules, no styled-components
- **shadcn/ui** for base components — customize via Tailwind, don't fork
- **`cn()` helper** for conditional classes (from `lib/utils`)
- **No inline styles** — Use Tailwind classes
## i18n (Internationalization)
- **next-intl** for all user-facing text
- Never hardcode UI text — always use `t('key')`
- Translation files: `messages/tr.json`, `messages/en.json`
- Default locale: Turkish (`tr`)
## Data Fetching
```typescript
// Server Component — fetch directly
async function CoursePage({ params }: { params: { id: string } }) {
const course = await getCourse(params.id);
return ;
}
// Client Component — TanStack Query
function CourseList() {
const { data, isLoading } = useQuery({
queryKey: ['courses'],
queryFn: () => apiClient.getCourses(),
});
}
```
## Forms
Always: React Hook Form + Zod schema
```typescript
const schema = z.object({
title: z.string().min(1, 'Required').max(200),
description: z.string().optional(),
});
type FormData = z.infer;
```
## Testing
- **Vitest** for unit tests
- **Playwright** for E2E tests
- Test user interactions, not implementation details
- Mock API calls, not components
---
# Python Conventions
> Python coding standards and project structure.
Source: https://devplaybook.tansuasici.com/docs/conventions/python
## Version & Tooling
- **Python 3.11+** — Use modern features (type hints, match statements, ExceptionGroups)
- **FastAPI** for web APIs
- **Pydantic v2** for data validation and settings
- **Ruff** for linting + formatting (replaces flake8, black, isort)
- **pytest** for testing
- **mypy** for type checking
## Project Structure
```text
project/
├── src/
│ └── project_name/
│ ├── __init__.py
│ ├── main.py ← FastAPI app entrypoint
│ ├── config.py ← Settings via pydantic-settings
│ ├── api/
│ │ ├── routes/ ← Route handlers
│ │ └── deps.py ← Dependency injection
│ ├── models/ ← Pydantic models (request/response)
│ ├── services/ ← Business logic
│ └── core/ ← Shared utilities
├── tests/
│ ├── conftest.py
│ ├── test_services/
│ └── test_api/
├── pyproject.toml ← Single config file (deps, ruff, mypy, pytest)
├── requirements.txt ← Pinned deps for CI/deployment
└── Dockerfile
```
## Naming
| Element | Convention | Example |
| -------------- | ------------------ | -------------------- |
| Files/modules | snake\_case | `course_service.py` |
| Functions | snake\_case | `get_course_by_id()` |
| Classes | PascalCase | `CourseService` |
| Constants | SCREAMING\_SNAKE | `MAX_CHUNK_SIZE` |
| Private | leading underscore | `_parse_document()` |
| Type variables | PascalCase | `T`, `ResponseT` |
## Type Hints
**Required everywhere.** No untyped public functions.
```python
def process_document(
file_path: Path,
chunk_size: int = 512,
overlap: int = 50,
) -> list[DocumentChunk]:
...
```
Use modern syntax:
- `list[str]` not `List[str]`
- `dict[str, int]` not `Dict[str, int]`
- `str | None` not `Optional[str]`
## Async
- All FastAPI route handlers should be `async def`
- Use `asyncio` for concurrent IO operations
- Use `httpx.AsyncClient` for HTTP calls (not `requests`)
- Never mix sync and async — pick one per service
## Configuration
Use pydantic-settings for all config:
```python
class Settings(BaseSettings):
database_url: str
redis_url: str = "redis://localhost:6379"
debug: bool = False
model_config = SettingsConfigDict(env_file=".env")
```
## Error Handling
- Use custom exception classes, not bare `raise Exception`
- FastAPI exception handlers for HTTP error responses
- Log exceptions with context, not just the message
- Never silently swallow exceptions with bare `except:`
## Testing
- **pytest** with async support (`pytest-asyncio`)
- Fixtures for shared setup
- Use `httpx.AsyncClient` with `ASGITransport` for API tests
- Naming: `test___`
```python
async def test_process_document_with_valid_pdf_returns_chunks():
...
```
## Dependencies
- Pin exact versions in `requirements.txt` for reproducibility
- Use ranges in `pyproject.toml` for library projects
- `pip-compile` or `uv` for lock file generation
- Review and update dependencies monthly
---
# Docker
> Container standards, Dockerfile patterns, and compose conventions.
Source: https://devplaybook.tansuasici.com/docs/conventions/docker
## Dockerfile Best Practices
### Multi-Stage Builds
Always use multi-stage builds to keep images small:
```dockerfile
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.sln .
COPY src/**/*.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Api.dll"]
```
### Rules
1. **Pin base image versions** — `node:22-alpine`, not `node:latest`
2. **Use alpine variants** when possible — smaller image, smaller attack surface
3. **Don't run as root** — Add a non-root user:
```dockerfile
RUN adduser --disabled-password --gecos "" appuser
USER appuser
```
4. **Order layers by change frequency** — Dependencies first (cached), source code last
5. **Use .dockerignore** — Exclude `node_modules/`, `bin/`, `obj/`, `.git/`, `.env`
6. **One process per container** — Don't run web server + background worker in the same container
7. **Health checks in Dockerfile**:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
```
## Docker Compose
### Development Stack Template
```yaml
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:8-alpine
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
```
### Compose Rules
1. **Use `.env` file** for all configurable values (ports, credentials, versions)
2. **Add health checks** to every service — dependent services use `depends_on: condition: service_healthy`
3. **Named volumes** for persistent data — never bind-mount database data directories
4. **Pin image versions** — `postgres:17-alpine`, not `postgres:latest`
5. **Don't expose ports in production** that should be internal-only
### Environment Variables
```bash
# .env.example (commit this)
DB_USER=myapp
DB_PASSWORD=change_me
DB_NAME=myapp_dev
DB_PORT=5432
REDIS_PORT=6379
# .env (do NOT commit)
DB_USER=myapp
DB_PASSWORD=actual_secure_password
DB_NAME=myapp_dev
DB_PORT=5432
REDIS_PORT=6379
```
## Image Naming
```text
ghcr.io//:
```
| Tag | When | Example |
| ------------ | ---------------------- | ----------------------------- |
| `latest` | Latest build from main | `ghcr.io/org/api:latest` |
| `sha-` | Specific commit | `ghcr.io/org/api:sha-abc1234` |
| `v1.2.0` | Release version | `ghcr.io/org/api:v1.2.0` |
## .dockerignore
```text
.git
.github
node_modules
bin
obj
.env
.env.*
*.md
tests
**/*.test.*
.DS_Store
```
## Common Mistakes
| Mistake | Fix |
| --------------------------------- | ------------------------------------------------------------- |
| Running as root | Add `USER` instruction |
| `COPY . .` before restore/install | Copy dependency files first, then `COPY . .` |
| No .dockerignore | Add one — `node_modules` in an image is a build time disaster |
| `latest` tag in production | Pin to specific version or SHA |
| Secrets in build args | Use runtime env vars or secrets mount |
| Giant images | Use multi-stage + alpine |
---
# Git Hooks
> Pre-commit, commit-msg, and pre-push hook standards.
Source: https://devplaybook.tansuasici.com/docs/conventions/git-hooks
## Purpose
Git hooks run automated checks before commits and pushes, catching problems before they reach the remote. Think of them as a local CI that runs instantly.
## Recommended Hooks
| Hook | When | What to Run |
| ------------ | ---------------------------- | ------------------------------------ |
| `pre-commit` | Before each commit | Lint + format staged files |
| `commit-msg` | After writing commit message | Validate Conventional Commits format |
| `pre-push` | Before pushing to remote | Run tests |
## Setup by Stack
### Node.js / Next.js — Husky + lint-staged
```bash
# Install
npm install --save-dev husky lint-staged
# Initialize husky
npx husky init
```
`.husky/pre-commit`:
```bash
npx lint-staged
```
`package.json`:
```json
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,yml}": ["prettier --write"]
}
}
```
`.husky/commit-msg`:
```bash
npx --no -- commitlint --edit "$1"
```
Install commitlint:
```bash
npm install --save-dev @commitlint/cli @commitlint/config-conventional
```
`commitlint.config.js`:
```javascript
export default { extends: ['@commitlint/config-conventional'] };
```
### .NET — Husky.Net
```bash
dotnet tool install Husky
dotnet husky install
dotnet husky add pre-commit -c "dotnet format --verify-no-changes"
dotnet husky add pre-commit -c "dotnet build --no-restore"
```
### Python — pre-commit
```bash
pip install pre-commit
```
`.pre-commit-config.yaml`:
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
```
```bash
pre-commit install
```
## Rules
1. **Hooks should be fast** — Under 5 seconds. If it takes longer, run only on staged files.
2. **Only check staged files** — Don't lint the entire codebase on every commit.
3. **Commit hook config to the repo** — `.husky/`, `.pre-commit-config.yaml` should be versioned.
4. **Document setup** — New developers should know to run the install command.
5. **Never skip hooks** — Don't use `--no-verify` unless you have a very good reason (and you probably don't).
## Troubleshooting
| Problem | Fix |
| ------------------------------ | ------------------------------------------------------------------------- |
| Hook not running | Run the install command again (`npx husky install`, `pre-commit install`) |
| Hook too slow | Use lint-staged to check only staged files, not the whole repo |
| False positive blocking commit | Fix the issue. Don't skip the hook. |
| Team member doesn't have hooks | Add `prepare` script: `"prepare": "husky"` in package.json |
---
# Documentation Standards
> README templates, API docs, and inline documentation rules.
Source: https://devplaybook.tansuasici.com/docs/conventions/documentation
## What to Document
| Type | Where | When |
| -------------------------- | ------------------------------------------------------------------------ | ----------------------------------------- |
| **API Reference** | OpenAPI/Swagger (auto-generated) | Always for public APIs |
| **Architecture Decisions** | `docs/adr/` or [architecture/decisions.md](/docs/architecture/decisions) | When making significant technical choices |
| **README** | Root of every repo | Always |
| **Inline Comments** | In the code | Only when the "why" isn't obvious |
| **Runbooks** | Wiki or `docs/runbooks/` | For operational procedures |
| **CLAUDE.md** | Root of every repo | For AI-assisted development |
## README Template
Every project README should include:
```markdown
# Project Name
One-line description of what this project does.
## Quick Start
### Prerequisites
- Node.js 20+
- Docker & Docker Compose
- PostgreSQL 16 (via Docker)
### Setup
\`\`\`bash
git clone
cd
cp .env.example .env
docker compose up -d
npm install
npm run dev
\`\`\`
### Running Tests
\`\`\`bash
npm test # unit tests
npm run test:e2e # end-to-end tests
\`\`\`
## Project Structure
\`\`\`
src/
├── modules/ ← Feature modules
├── shared/ ← Shared utilities
└── config/ ← Configuration
\`\`\`
## API Documentation
Available at `/swagger` when running locally.
## Deployment
See [CI/CD pipeline](.github/workflows/ci.yml) for automated deployment.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[MIT](https://github.com/tansuasici/dev-playbook/blob/main/LICENSE)
```
## API Documentation
### OpenAPI/Swagger
- **Auto-generate** from code annotations — don't maintain a separate spec file
- **Include examples** for request/response bodies
- **Document all error responses**, not just the happy path
- **Group endpoints** by resource or feature
```csharp
// .NET — Swagger annotations
[HttpPost]
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task CreateCourse([FromBody] CreateCourseRequest request)
```
```typescript
// Next.js / Express — JSDoc or zod-to-openapi
/**
* @openapi
* /api/courses:
* post:
* summary: Create a new course
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateCourseRequest'
* responses:
* 201:
* description: Course created successfully
* 400:
* description: Validation error
*/
```
```python
# Python — FastAPI auto-generates OpenAPI
@router.post("/courses", response_model=CourseResponse, status_code=201)
async def create_course(request: CreateCourseRequest):
"""Create a new course.
Raises:
HTTPException(400): Invalid input
HTTPException(409): Duplicate course title
"""
```
## Inline Comments
### When to Comment
- **Why**, not what — the code already says what it does
- **Business rules** that aren't obvious from code
- **Workarounds** with links to issues or tickets
- **Performance decisions** that look wrong but are intentional
- **Regex patterns** — always explain what they match
### When NOT to Comment
- Obvious code (`i++ // increment i`)
- Function names that are self-explanatory
- TODO/FIXME without issue references
- Commented-out code — delete it, git has history
### Examples
```csharp
// GOOD — explains WHY
// Tenant isolation: RLS handles filtering, but we double-check here
// because admin endpoints bypass RLS for cross-tenant reports
if (!user.IsAdmin && course.TenantId != user.TenantId)
throw new ForbiddenException();
// GOOD — explains business rule
// Students can unenroll within 14 days of enrollment (refund policy)
if (enrollment.CreatedAt.AddDays(14) < DateTime.UtcNow)
throw new BusinessRuleException("Unenrollment period has expired");
// BAD — states the obvious
// Check if user is null
if (user == null) ...
// BAD — no issue reference
// TODO: fix this later
```
## CLAUDE.md
Every project should have a `CLAUDE.md` at the root for AI-assisted development.
See [templates/claude-md-starter.md](/docs/templates/claude-md-starter) for the starter template.
### Must Include
- Project description and tech stack
- How to build, test, and run
- Key architecture decisions
- Link to this playbook for standards
- Project-specific conventions not covered by the playbook
## Changelogs
### For Libraries / Packages
Follow [Keep a Changelog](https://keepachangelog.com/) format:
```markdown
## [1.2.0] - 2025-03-15
### Added
- Course capacity limits
### Fixed
- Login redirect with query parameters
```
### For Applications
Use **release notes** tied to version tags instead of maintaining a CHANGELOG file. The git history and PR descriptions are the changelog.
## Anti-Patterns
- **No README** — Every project needs one, even internal tools
- **Stale documentation** — Outdated docs are worse than no docs. Keep it updated or delete it.
- **Documentation in a separate system** — Keep docs close to code (same repo) when possible
- **Over-documenting internals** — Code should be self-explanatory. Document interfaces, not implementations.
- **Screenshots without context** — Always explain what the screenshot shows and when it was taken
---
# Environment Management
> Dev/staging/prod environment setup and feature flags.
Source: https://devplaybook.tansuasici.com/docs/conventions/environment-management
## Environments
| Environment | Purpose | Deploys From | Who Uses It |
| -------------- | ---------------------- | ----------------------------- | -------------------- |
| **Local** | Development | Developer machine | Individual developer |
| **Staging** | Pre-production testing | `main` branch (auto) | Team, QA |
| **Production** | Live users | Version tags (manual trigger) | Everyone |
### Optional Environments
| Environment | When to Use |
| ------------- | ----------------------------------------------- |
| **Preview** | Per-PR deployments for review (Vercel, Netlify) |
| **QA** | Dedicated QA testing with controlled data |
| **Load Test** | Performance testing with production-like data |
## Configuration Management
### Environment Variables
Use `.env` files for local development, environment variables or secrets managers for deployed environments.
```text
.env → Local defaults (git-ignored)
.env.example → Template with placeholder values (committed)
.env.test → Test-specific overrides (committed, no secrets)
```
### .env.example
Always maintain a `.env.example` with every variable documented:
```bash
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
# Redis
REDIS_URL=redis://localhost:6379
# Auth
JWT_SECRET=your-secret-here
JWT_EXPIRY_MINUTES=15
# External Services
STORAGE_BUCKET=my-bucket
STORAGE_ENDPOINT=http://localhost:9000
# Feature Flags
FEATURE_NEW_DASHBOARD=false
FEATURE_AI_CHAT=false
# App
APP_URL=http://localhost:3000
API_URL=http://localhost:5000
LOG_LEVEL=Information
```
### Rules
- **Never commit `.env` files** with real values
- **Every new env var** must be added to `.env.example` in the same PR
- **Use descriptive names**: `DATABASE_URL` not `DB`, `JWT_SECRET` not `SECRET`
- **Document expected format** in comments when not obvious
- **Group by category** (database, auth, external services, features)
### Accessing Config
```csharp
// .NET — Use Options pattern, never read env vars directly in services
public class JwtSettings
{
public string Secret { get; set; }
public int ExpiryMinutes { get; set; }
}
// Registration
builder.Services.Configure(builder.Configuration.GetSection("Jwt"));
```
```typescript
// TypeScript — Validate at startup, fail fast
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'test', 'production']),
});
export const env = envSchema.parse(process.env);
```
```python
# Python — Use pydantic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
jwt_secret: str
log_level: str = "INFO"
class Config:
env_file = ".env"
settings = Settings()
```
## Feature Flags
Use feature flags to control feature rollout without redeploying.
### When to Use
- Releasing a feature incrementally (% of users)
- Gating a feature behind a tenant or role
- Quick kill switch for risky features
- A/B testing
### Implementation
```typescript
// Simple env-based flags (good for small projects)
const features = {
newDashboard: process.env.FEATURE_NEW_DASHBOARD === 'true',
aiChat: process.env.FEATURE_AI_CHAT === 'true',
};
if (features.newDashboard) {
// render new dashboard
}
```
```csharp
// .NET — Feature Management library
builder.Services.AddFeatureManagement();
// Usage
if (await _featureManager.IsEnabledAsync("NewDashboard"))
{
// new behavior
}
```
### Rules
- **Name clearly**: `FEATURE_NEW_DASHBOARD` not `FLAG_1`
- **Default to off** for new features
- **Clean up after rollout** — remove the flag and dead code path once the feature is fully launched
- **Log flag evaluations** — know which users see which features
- **Document every flag** with purpose, owner, and expected removal date
### Flag Lifecycle
```text
Created → Testing → Rollout (10% → 50% → 100%) → Cleanup (remove flag + old code)
```
## Secrets Management
### By Environment
| Environment | Strategy |
| -------------- | --------------------------------------------------------------- |
| **Local** | `.env` file (git-ignored) |
| **CI/CD** | GitHub Actions Secrets / pipeline variables |
| **Staging** | Secrets manager (Azure Key Vault, AWS Secrets Manager, Doppler) |
| **Production** | Secrets manager with audit logging and rotation |
### Rules
- **Never hardcode secrets** in source code
- **Rotate secrets** after team member departures
- **Use different secrets per environment** — staging and production must never share
- **Audit access** — know who can read production secrets
- **Least privilege** — each service gets only the secrets it needs
## Environment Parity
Keep environments as similar as possible to catch issues early.
### What Must Match
- Same database engine and version (PostgreSQL 16, not SQLite for dev)
- Same cache engine (Redis, not in-memory for dev)
- Same message broker
- Same container runtime
### What Can Differ
- Resource allocation (fewer replicas, smaller instances)
- External service endpoints (sandbox APIs vs production)
- Data volume (subset of production data, anonymized)
- Domain names and certificates
### Docker Compose for Local Parity
```yaml
# docker-compose.yml — mirrors production dependencies
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
```
## Anti-Patterns
- **"Works on my machine"** — Use Docker Compose to ensure local parity with production
- **Shared staging database** — Each PR/branch should ideally get isolated data
- **Secrets in code or git history** — If it happened, rotate the secret immediately
- **No `.env.example`** — New developers shouldn't have to guess what variables are needed
- **Feature flags that live forever** — Set a removal date when creating the flag
---
# Accessibility (a11y)
> Web accessibility standards and WCAG compliance.
Source: https://devplaybook.tansuasici.com/docs/conventions/accessibility
## Core Principle
**Every user should be able to use the application regardless of ability.** Accessibility is not optional — it's a quality requirement like security or performance.
Target: **WCAG 2.1 Level AA** compliance.
## Semantic HTML
Use the right element for the job. Semantic HTML gives you accessibility for free.
```html
...View courses
Submit
...
...
navigate('/courses')}>View courses
```
### Heading Hierarchy
```html
Course Dashboard
Active Courses
Introduction to Psychology
Completed Courses
Course Dashboard
Active Courses
```
Never skip heading levels. Use CSS for visual sizing, not heading tags.
## Keyboard Navigation
### Requirements
- All interactive elements must be **focusable** and **operable** with keyboard
- **Tab order** must follow visual order (don't rearrange with `tabIndex > 0`)
- **Focus must be visible** — never remove focus outlines without replacement
- **Escape** should close modals, dropdowns, and popovers
- **Enter/Space** should activate buttons and links
### Focus Management
```typescript
// After opening a modal, move focus into it
function openModal() {
setIsOpen(true);
// Focus the first focusable element in the modal
requestAnimationFrame(() => {
modalRef.current?.querySelector('[tabindex="-1"], button, input')?.focus();
});
}
// After closing, return focus to the trigger element
function closeModal() {
setIsOpen(false);
triggerRef.current?.focus();
}
```
### Focus Trap
Modals and dialogs must trap focus — Tab should cycle within the modal, not escape to the page behind it.
```typescript
// Use a library like @radix-ui/react-dialog or headlessui
// They handle focus trapping, Escape to close, and focus restoration
import * as Dialog from '@radix-ui/react-dialog';
Open
{/* Focus is automatically trapped here */}
```
## Images and Media
### Images
```html
Three microservices communicate via message queue.
The API Gateway routes requests to Course, User, and Analytics services.
```
### Video and Audio
- Provide **captions** for all video content
- Provide **transcripts** for audio content
- Never autoplay media with sound
- Provide playback controls (play, pause, volume, seek)
## Forms
```html
We'll never share your email
{hasError &&
Please enter a valid email
}
Invalid email
```
### Form Rules
- Every input must have a visible `