Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,253 @@ There are 3 contexts in which code executes:

Understanding in which environment code runs, and at which stage in the process, can help clarify thinking about what Astro is doing. It also helps with debugging, for instance, if you’re working within `src/core/`, you know that your code isn’t executing within Vite, so you don’t have to debug Vite’s setup. But you will have to debug vite inside `runtime/server/`.

### Making code testable

To make it easier to test code in Astro, we decouple **core** logic from **infrastructure**:

- **Infrastucture** is code that depends on external systems and/or requires aspecial environment to run. For example: DB calls, file system, randomness etc...
- **Core** logic (or *business logic* or *domain*) is the rest. It's pure logic that's easy to run from anywhere

#### File structure

Applying these principles can take many shapes. We adopted the following file structure for a few features (fonts, CLI):

```
feature/
core/
domain/
infra/
definitions.ts
```

If some infrastracture end up being shared with another feature, move it to a shared place that makes sense. For example:

```diff
feature-a/
core/
domain/
infra/
definitions.ts
feature-b/
core/
domain/
infra/
definitions.ts
+infra/
+definitions.ts
```

##### `definition.ts`

Contains types that represents our infrastructure. For example:

```ts
export interface KeyGenerator {
generate: () => Promise<string>;
}
```

This file can contain as many types as necessary. Breaking down some logic often requires creating several abstractions, each with its own responsabilities.

##### `core/`

Contains the core logic of the feature. Each file should be dedicated to one export (could be a function or an instance of a domain entity if it makes sense). For example `create-key.ts`:

```ts
import type { KeyGenerator, Logger } from '../definitions.js';

interface Options {
keyGenerator: KeyGenerator;
logger: Logger;
}

export async function createKey({ keyGenerator, logger }: Options) {
const key = await keyGenerator.generate();
logger.info(`Key created: ${key}`)
}
```

It should almost always reference types you defined in `definitions.ts`, and compose them together. If you can't test conveniently part of it, or have to mock, introduce new infrastructure.

Note: the file itself can contain several functions or variables, that are used locally by the export.

##### `infra/`

Contains implementations of infrastructure. The filename and the class name must represent how it's implemented. For example `crypto-key-generator.ts`:

```ts
import type { KeyGenerator } from '../definitions.js';
import { encodeBase64 } from '@oslojs/encoding';

export class CryptoKeyGenerator implements KeyGenerator {
readonly #algorithm = 'AES-GCM';

async generate(): Promise<string> {
const key = await crypto.subtle.generateKey(
{
name: this.#algorithm,
length: 256,
},
true,
['encrypt', 'decrypt'],
);
const encoded = encodeBase64(new Uint8Array(await crypto.subtle.exportKey('raw', key)));
return encoded;
}
}
```

A given infrastructure implementation can rely on infrastructure itself. For example:

```ts
import type { KeyGenerator, KeyEncoder } from '../definitions.js';
import { encodeBase64 } from '@oslojs/encoding';

export class CryptoKeyGenerator implements KeyGenerator {
readonly #algorithm = 'AES-GCM';
readonly #keyEncoder: KeyEncoder;

constructor({ keyEncoder }: {
keyEncoder: KeyEncoder;
}) {
this.#keyEncoder = keyEncoder;
}

async generate(): Promise<string> {
const key = await crypto.subtle.generateKey(
{
name: this.#algorithm,
length: 256,
},
true,
['encrypt', 'decrypt'],
);
const encoded = this.#keyEncoder.encode(key);
return encoded;
}
}
```

We recommend using classes and the `implements` keyword.

##### `domain/`

Contains reusable data types or abstractions. You can choose the create one file per abstraction to be explicit (like for `infra/`) or one `domain.ts` file.

Our examples so far do not need domain abstraction. But let's imagine we're building a CLI, we could have `domain/help-payload.ts`:

```ts
export interface HelpPayload {
commandName: string;
headline?: string;
usage?: string;
tables?: Record<string, [command: string, help: string][]>;
description?: string;
}
```

Or `domain/command.ts`:

```ts
import type { HelpPayload } from './help-payload.js';

interface Command<T extends (...args: Array<any>) => any> {
help: HelpPayload;
run: T;
}

export type AnyCommand = Command<(...args: Array<any>) => any>;

export function defineCommand<T extends AnyCommand>(command: T) {
return command;
}
```

As you can see, it doesn't have to be only types. It could even be classes.

#### Usage

With these independent bricks, you can now compose them in your public API entrypoints, Vite plugin, etc. For example in the CLI:

```ts
import { CryptoKeyGenerator } from './create-key/infra/crypto-key-generator.js';
import { createKey } from './create-key/core/create-key.js';
import { FlagsLogger } from './infra/flags-logger.js';

async function runCommand(command: string, flags: Flags) {
const logger = new FlagsLogger({ flags });

switch (command) {
case 'create-key': {
const keyGenerator = new CryptoKeyGenerator()
return await createKey({ keyGenerator, logger });
}
}
}
```

#### Testing

The power of this whole architecture is to make it unit testable. Because abstractions hold very specific responsabilities, we can easily mock them:

```js
// @ts-check
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createKey } from '../../../dist/cli/create-key/core/create-key.js';
import { SpyLogger } from '../test-utils.js';
import { FakeKeyGenerator } from './utils.js';

describe('CLI create-key', () => {
describe('core', () => {
describe('createKey()', () => {
it('logs the generated key', async () => {
const logger = new SpyLogger();
const keyGenerator = new FakeKeyGenerator('FOO');

await createKey({ logger, keyGenerator });

assert.equal(logger.logs[0].type, 'info');
assert.equal(logger.logs[0].label, 'crypto');
assert.match(logger.logs[0].message, /ASTRO_KEY=FOO/);
});
});
});
});
```

It can be useful to create test specific abstractions:

```js
// @ts-check

/**
* @import { KeyGenerator } from "../../../dist/cli/create-key/definitions.js"
*/

/** @implements {KeyGenerator} */
export class FakeKeyGenerator {
/** @type {string} */
#key;

/**
* @param {string} key
*/
constructor(key) {
this.#key = key;
}

async generate() {
return this.#key;
}
}
```

Remember:

- Test what you're afraid of breaking. For example if an infrastructure implementation is just a wrapper around a NPM package, you may not need to test it and instead trust the package tests
- Always test core logic

## Branches

### `main`
Expand Down