Практические заметки: моя настройка monorepo для развертывания полноценных фронтенд- и бэкенд-частей с типобезопасностью от начала до конца
Пошаговое руководство по практическим советам: настройка моего monorepo для разработки полноценных, крайне безопасных с точки зрения типов решений — контракты, проверки и готовые блоки кода для команд, использующих эту модель.
Используйте это как переработанную версию идей из статьи «Моя настройка монорепозитория для разработки полноценных, кросс-фреймворковых приложений с типобезопасностью на TypeScript, часть 2», ориентированную на операторов: четкие этапы, упорядоченные блоки кода и записи для восстановления, которые сохраняются при передаче задач. Этап Обзора работает наилучшим образом, если рассматривать его как измеримую основу. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Предпочитайте небольшие, тестируемые единицы кода большим скриптам. Когда какой-либо шаг срывается, причина сбоя должна указывать на конкретную ответственность, а не на запутанную цепочку операций.
name: monorepo-setup
services:
db:
image: postgres:17-alpine
container_name: monorepo-setup-database-container
ports:
- 127.0.0.1:5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- monorepo-setup:/var/lib/postgresql/data
env_file:
- apps/api/.env.database
volumes:
monorepo-setup:
name: monorepo-setup
# apps/api/.env.database
POSTGRES_DB=monorepo-setup
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
docker compose up -d --remove-orphans
docker ps
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
# monorepo-setup-database-container is the name of the container, as specified in the compose.yaml file
# -U postgres (POSTGRES_USER specified in the .env.database file)
# same for -d monorepo-setup (POSTGRES_DB)
# From the root of the monorepo
cd apps/api
npm i drizzle-orm@rc pg dotenv
npm i -D drizzle-kit@rc tsx @types/pg
DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>
DATABASE_URL=postgres://postgres:postgres@localhost:5432/monorepo-setup
nest g res drizzle
// apps/api/src/drizzle/constants.ts
export const DRIZZLE_ASYNC_PROVIDER = "DRIZZLE_ASYNC_PROVIDER";
// apps/api/drizzle/providers/drizzle.provider.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { EnvService } from "@/env/env.service";
import { DRIZZLE_ASYNC_PROVIDER } from "../constants";
export const drizzleProvider = {
provide: DRIZZLE_ASYNC_PROVIDER,
inject: [EnvService],
useFactory: async (envService: EnvService) => {
const connectionString = envService.get("DATABASE_URL");
const pool = new Pool({
connectionString,
});
return drizzle({ client: pool });
},
};
// apps/api/src/drizzle/schema/users.ts
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";
export const users = pgTable("users", {
id: varchar("id", { length: 26 })
.primaryKey()
.notNull()
.$defaultFn(() => ulid().toLowerCase()),
username: text("username").notNull().unique(),
email: text("email").notNull().unique(),
hashedPassword: text("hashed_password").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
// apps/api/src/drizzle/schema/posts.ts
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";
import { users } from "./users";
export const posts = pgTable("posts", {
id: varchar("id", { length: 26 })
.primaryKey()
.notNull()
.$defaultFn(() => ulid().toLowerCase()),
authorId: varchar("author_id", { length: 26 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
slug: text("slug").notNull(),
content: text("content").notNull(),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
// apps/api/src/drizzle/schema/index.ts
export * from "./posts";
export * from "./users";
npm i ulid
// apps/api/src/env.ts
import { z } from "zod";
export const envSchema = z.object({
NODE_ENV: z.enum(["production", "development"]),
JWT_SECRET: z.string().trim().min(1),
DASHBOARD_URL: z.url(),
WEB_APP_URL: z.url(),
DATABASE_URL: z.string().trim().min(1),
});
export type Env = z.infer<typeof envSchema>;
// apps/api/drizzle.config.ts
import { defineConfig } from "drizzle-kit";
import "dotenv/config";
export default defineConfig({
out: "./src/drizzle/migrations",
schema: "./src/drizzle/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
// apps/api/package.json
{
"scripts": {
...
"generate": "npx drizzle-kit generate",
"migrate": "npx drizzle-kit migrate",
...
}
}
npm run generate
npm run generate
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
\dt
// apps/api/package.json
{
"scripts": {
...
"studio": "npx drizzle-kit studio"
...
}
}
npm run studio
import { EnvModule } from "@/env/env.module";
import { Module } from "@nestjs/common";
import { DRIZZLE_ASYNC_PROVIDER } from "./constants";
import { drizzleProvider } from "./providers/drizzle.provider";
@Module({
imports: [EnvModule],
providers: [drizzleProvider],
exports: [DRIZZLE_ASYNC_PROVIDER],
})
export class DrizzleModule {}
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { EnvService } from "./env.service";
@Module({
imports: [ConfigModule],
providers: [EnvService],
exports: [EnvService],
})
export class EnvModule {}
nest g res users
// apps/api/src/users/users.service.ts
import * as bcrypt from "bcrypt";
import { eq, or } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";
import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { users } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";
import { CreateUserDtoType, FindByEmailDtoType } from "./users.dto";
@Injectable()
export class UsersService {
private readonly saltRounds = 10;
constructor(
@Inject(DRIZZLE_ASYNC_PROVIDER)
private readonly db: NodePgDatabase,
) {}
async create(createUserDto: CreateUserDtoType) {
const { email, password, username } = createUserDto;
const [existingUserWithSameEmailOrUsername] = await this.db
.select({ email: users.email, username: users.username })
.from(users)
.where(or(eq(users.email, email), eq(users.username, username)))
.limit(1);
if (existingUserWithSameEmailOrUsername) {
if (existingUserWithSameEmailOrUsername.email === email) {
throw new TRPCError({
code: "CONFLICT",
message: "This email is already used",
});
} else {
throw new TRPCError({
code: "CONFLICT",
message: "This username already exists",
});
}
}
const hashedPassword = await bcrypt.hash(password, this.saltRounds);
const [createdUser] = await this.db
.insert(users)
.values({
username,
email,
hashedPassword,
})
.returning({
id: users.id,
email: users.email,
username: users.username,
});
return createdUser;
}
async findByEmail(findByEmailDto: FindByEmailDtoType) {
const { email } = findByEmailDto;
const [user] = await this.db
.select({
id: users.id,
email: users.email,
username: users.username,
hashedPassword: users.hashedPassword,
})
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
return null;
}
return user;
}
}
// apps/api/src/users/users.dto.ts
import z from "zod";
export const CreateUserDto = z.object({
email: z.email(),
username: z.string().min(1).max(25),
password: z.string().min(1).max(72),
});
export const FindByEmailDto = z.object({
email: z.email(),
});
export type CreateUserDtoType = z.infer<typeof CreateUserDto>;
export type FindByEmailDtoType = z.infer<typeof FindByEmailDto>;
# apps/api
npm i bcrypt
npm i -D @types/bcrypt
// apps/api/src/users/users.module.ts
import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";
@Module({
imports: [DrizzleModule],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
nest g res posts
import { and, eq } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";
import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { posts } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";
import {
CreatePostDtoType,
FindAllPostsDtoType,
FindOnePostDtoType,
} from "./posts.dto";
@Injectable()
export class PostsService {
constructor(
@Inject(DRIZZLE_ASYNC_PROVIDER)
private readonly db: NodePgDatabase,
) {}
async create(createPostDto: CreatePostDtoType) {
const { title, slug, content, authorId } = createPostDto;
const [existingPostWithSameSlug] = await this.db
.select({ slug: posts.slug })
.from(posts)
.where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
.limit(1);
if (existingPostWithSameSlug) {
throw new TRPCError({
code: "CONFLICT",
message: "A post with that slug already exists",
});
}
const [createdPost] = await this.db
.insert(posts)
.values({ title, slug, content, authorId, publishedAt: new Date() })
.returning({
id: posts.id,
title: posts.title,
slug: posts.slug,
content: posts.content,
authorId: posts.authorId,
});
return createdPost;
}
async findAll(findAllPostsDto: FindAllPostsDtoType) {
const { authorId } = findAllPostsDto;
const postsFetched = await this.db
.select({ title: posts.title, slug: posts.slug, content: posts.content })
.from(posts)
.where(eq(posts.authorId, authorId));
return postsFetched;
}
async findOne(findOnePostDto: FindOnePostDtoType) {
const { authorId, slug } = findOnePostDto;
const [post] = await this.db
.select({
title: posts.title,
slug: posts.slug,
content: posts.content,
})
.from(posts)
.where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
.limit(1);
if (!post) {
return null;
}
return post;
}
}
// apps/api/src/posts/posts.dto.ts
import z from "zod";
export const CreatePostDto = z.object({
title: z.string().min(1).max(150),
slug: z.string().min(1).max(150),
content: z.string().min(1),
});
export const FindOnePostDto = z.object({
slug: z.string().min(1).max(150),
});
type AuthorId = { authorId: string };
export type CreatePostDtoType = z.infer<typeof CreatePostDto> & AuthorId;
export type FindAllPostsDtoType = AuthorId;
export type FindOnePostDtoType = z.infer<typeof FindOnePostDto> & AuthorId;
// apps/api/src/posts/posts.module.ts
import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";
import { PostsService } from "./posts.service";
@Module({
imports: [DrizzleModule],
providers: [PostsService],
exports: [PostsService],
})
export class PostsModule {}
nest g res auth
// apps/api/src/auth/auth.service.ts
import * as bcrypt from "bcrypt";
import { JWTDtoType } from "@/common/dto";
import { UsersService } from "@/users/users.service";
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { TRPCError } from "@trpc/server";
import { LoginDtoType, RegisterDtoType } from "./auth.dto";
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly usersService: UsersService,
) {}
async register(registerDto: RegisterDtoType) {
const { username, email, password } = registerDto;
const createdUser = await this.usersService.create({
email,
username,
password,
});
const payload: Pick<JWTDtoType, "sub"> = { sub: createdUser.id };
const token = await this.jwtService.signAsync(payload);
return { accessToken: token };
}
async login(loginDto: LoginDtoType) {
const { email, password } = loginDto;
const user = await this.usersService.findByEmail({ email });
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const isPasswordCorrect = await bcrypt.compare(
password,
user.hashedPassword,
);
if (!isPasswordCorrect) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Incorrect password",
});
}
const payload: Pick<JWTDtoType, "sub"> = { sub: user.id };
const token = await this.jwtService.signAsync(payload);
return {
accessToken: token,
};
}
}
// apps/api/src/auth/auth.dto.ts
import z from "zod";
export const RegisterDto = z.object({
username: z.string().min(1).max(25),
email: z.email(),
password: z.string().min(1).max(72),
});
export const LoginDto = z.object({
email: z.email(),
password: z.string().min(1).max(72),
});
export type RegisterDtoType = z.infer<typeof RegisterDto>;
export type LoginDtoType = z.infer<typeof LoginDto>;
// apps/api/src/auth/auth.router.ts
import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";
import { LoginDto, RegisterDto } from "./auth.dto";
import { AuthService } from "./auth.service";
@Injectable()
export class AuthRouter {
constructor(
private readonly trpcService: TrpcService,
private readonly authService: AuthService,
) {}
private readonly AUTH_COOKIE_NAME = "auth_token";
private readonly COOKIE_MAX_AGE = 2 * 24 * 60 * 60 * 1000; // 2 days
procedures() {
return {
auth: this.trpcService.trpc.router({
register: this.trpcService
.publicProcedure()
.input(RegisterDto)
.mutation(async ({ ctx, input }) => {
const { accessToken } = await this.authService.register(input);
// Set the HTTP-only cookie
ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: this.COOKIE_MAX_AGE,
});
}),
login: this.trpcService
.publicProcedure()
.input(LoginDto)
.mutation(async ({ ctx, input }) => {
const { accessToken } = await this.authService.login(input);
// Set the HTTP-only cookie
ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: this.COOKIE_MAX_AGE,
});
}),
}),
};
}
}
// apps/api/src/auth/auth.module.ts
import { EnvModule } from "@/env/env.module";
import { EnvService } from "@/env/env.service";
import { TrpcService } from "@/trpc/trpc.service";
import { UsersModule } from "@/users/users.module";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { AuthRouter } from "./auth.router";
import { AuthService } from "./auth.service";
@Module({
imports: [
ConfigModule,
UsersModule,
JwtModule.registerAsync({
imports: [EnvModule],
useFactory: async (envService: EnvService) => ({
global: true,
secret: envService.get("JWT_SECRET"),
signOptions: { expiresIn: "2d" },
}),
inject: [EnvService],
}),
],
providers: [AuthService, AuthRouter, TrpcService, EnvService],
exports: [AuthRouter, AuthService],
})
export class AuthModule {}
// apps/api/src/trpc/trpc.router.ts
appRouter = this.trpcService.trpc.router({
getHello: this.trpcService
.publicProcedure()
.input(
z.object({
name: z.string().min(1),
}),
)
.query(({ input }) => `Hello, ${input.name} from trpc server`),
...this.authRouter.procedures(),
});
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule],
providers: [TrpcService, TrpcRouter, JwtService, EnvService, AuthRouter],
exports: [TrpcService],
})
// apps/api/src/posts/posts.router.ts
import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";
import { CreatePostDto, FindOnePostDto } from "./posts.dto";
import { PostsService } from "./posts.service";
@Injectable()
export class PostsRouter {
constructor(
private readonly trpcService: TrpcService,
private readonly postsService: PostsService,
) {}
procedures() {
return {
posts: this.trpcService.trpc.router({
create: this.trpcService
.protectedProcedure()
.input(CreatePostDto)
.mutation(async ({ ctx, input }) =>
this.postsService.create({ ...input, authorId: ctx.user.sub }),
),
findAll: this.trpcService
.protectedProcedure()
.query(async ({ ctx }) =>
this.postsService.findAll({ authorId: ctx.user.sub }),
),
findOne: this.trpcService
.protectedProcedure()
.input(FindOnePostDto)
.query(async ({ ctx, input }) =>
this.postsService.findOne({ ...input, authorId: ctx.user.sub }),
),
}),
};
}
}
// apps/api/src/trpc/trpc.router.ts
appRouter = this.trpcService.trpc.router({
getHello: this.trpcService
.publicProcedure()
.input(
z.object({
name: z.string().min(1),
}),
)
.query(({ input }) => `Hello, ${input.name} from trpc server`),
...this.authRouter.procedures(),
...this.postsRouter.procedures(),
});
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule, PostsModule],
providers: [
TrpcService,
TrpcRouter,
JwtService,
EnvService,
AuthRouter,
PostsRouter,
],
exports: [TrpcService],
})
import { Controller, useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { useTRPC } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginSchema, type LoginSchemaType } from "@repo/common/types-schemas";
import { Button } from "@repo/ui/components/ui/button";
import { Field, FieldError, FieldLabel } from "@repo/ui/components/ui/field";
import { Input } from "@repo/ui/components/ui/input";
import { useMutation } from "@tanstack/react-query";
import { useTogglePassword } from "../hooks/use-toggle-password";
export const LoginForm = () => {
const form = useForm<LoginSchemaType>({
resolver: zodResolver(LoginSchema),
defaultValues: {
email: "",
password: "",
},
});
const { isPasswordVisible, EyeIconComponent } = useTogglePassword();
const navigate = useNavigate();
const trpc = useTRPC();
const loginMutation = useMutation(trpc.auth.login.mutationOptions());
const onSubmit = (values: LoginSchemaType) => {
loginMutation.mutate(
{
email: values.email,
password: values.password,
},
{
onError: (error) => {
const errorMessage = error.message;
if (errorMessage.toLowerCase().includes("email")) {
form.setError("email", { message: errorMessage });
} else if (errorMessage.toLowerCase().includes("password")) {
form.setError("password", { message: errorMessage });
} else {
form.setError("root", { message: errorMessage });
}
},
onSuccess: () => {
toast.success("Login complete");
navigate("/posts");
},
},
);
};
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-[clamp(40%,30rem,80%)] flex-col gap-4 p-2"
>
<h2 className="flex flex-col items-center justify-center gap-2 text-center text-3xl font-extrabold max-[42.5rem]:text-2xl">
Login
</h2>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
id={field.name}
placeholder="example@email.com"
type="email"
aria-invalid={fieldState.invalid}
className="border-border h-10"
{...field}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
<div className="relative flex items-center justify-end">
<Input
id={field.name}
placeholder="***********"
type={isPasswordVisible ? "text" : "password"}
aria-invalid={fieldState.invalid}
className="border-border h-10"
{...field}
/>
<EyeIconComponent />
</div>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<p>
Not registered yet?{" "}
<Link to="/register" className="underline">
Register
</Link>
</p>
<Button
variant="default"
type="submit"
disabled={form.formState.isSubmitting}
className="h-10 w-1/2 self-center rounded-lg"
>
Submit
</Button>
<div className="h-4">
{form.formState.errors.root && (
<FieldError errors={[form.formState.errors.root]} />
)}
</div>
</form>
);
};
Чек-лист операций
При работе над этапом операционного чек-листа сначала запишите условия контракта: необходимые входные данные, сигнал успешного выполнения и действия при частичной неудаче. Такой чек-лист помогает сохранять честность при последующих изменениях кода.
Задокументируйте одновременно «успешный путь» и путь восстановления. Повторные попытки, проверки со стороны оператора и обработка некорректных сообщений являются частью продукта, а не элементами последующей доработки.
Напишите краткое руководство по эксплуатации: как обновлять ключи, как опустошать очередь, как откатывать последнюю загрузку данных.
Храните конфигурацию отдельно от кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, чтобы операторы могли их проверять, не читая весь кодовый базис.
Когда позволяют бюджетные ограничения, добавьте тест на проверку критического пути в процессе CI с использованием фикстур, а не реальных платных API.
Записывайте время выполнения и стоимость токенов или запросов рядом с функциональными результатами. Отображение стоимости заранее помогает избежать неожиданных счетов при переходе с демо-среды в общедоступные среды.
Перед тем как переводить решение в более широкое использование, заморозьте версии, сохраните эталонный отчет для критически важных этапов и уточните шаги для отката. В общедоступных средах необходимы ограничения на количество запросов, проверки принадлежности и четко определенный ответственный за обновление секретов. Лучше выбирать надежность, чем креативные одноразовые демонстрации.
Примечание для 401e3ffdff5d: не храните ключи поставщика в репозитории, установите лимит на количество токенов за сессию и сохраняйте отчеты рядом с фикстурами для оценки, чтобы последующие замены моделей оставались сопоставимыми.
На этапе 0 записей по укреплению безопасности лучше всего работает подход, при котором рассматривается измеримая поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Записывайте время выполнения, стоимость токенов или запросов рядом с функциональными результатами. Отслеживание затрат на раннем этапе предотвращает неожиданные счета при переходе от демо-среды к общедоступным средам.
Подробность укрепления безопасности 0/962: измерьте время выполнения, класс ошибки и расход токенов для данной записи, затем решите, следует ли сохранять изменения, опираясь на фиксированный набор вопросов, а не на устные описания.
Для этапа 1 записей по укреплению безопасности определите входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Документируйте как успешный путь выполнения, так и путь восстановления. Повторные попытки, проверки человеком и обработка неработающих сообщений являются частью продукта, а не элементами последующей доработки.
Подробность усиления 1/962: измерьте время выполнения, класс ошибки и расход токенов для этой записи, затем решите, следует ли сохранить изменение на основе фиксированного набора вопросов, а не на основе единичных примеров.
При работе над вторым этапом записи о усилении сначала запишите контракт: необходимые входные данные, сигнал успешного выполнения и что происходит при частичной неудаче. Такой чек-лист обеспечивает честность последующих изменений в коде. Рассматривайте этот этап как контракт между входными данными и проверенными выходными данными. Дайте названия элементам, определите критерии успеха и не допускайте безответственного частичного выполнения задачи.
Подробность усиления 2/962: измерьте время выполнения, класс ошибки и расход токенов для этой записи, затем решите, следует ли сохранить изменение на основе фиксированного набора вопросов, а не на основе единичных примеров.
Этап 3 инструкций по усилению безопасности работает наилучшим образом, если рассматривать его как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, чтобы операторы могли их проверять, не читая весь код.
Подробность усиления безопасности 3/962: измеряйте время выполнения, класс ошибок и расход токенов для данной инструкции, затем принимайте решение о сохранении изменений на основе фиксированного набора критериев, а не на основе единичных примеров.
Для четвертой стадии усиления безопасности необходимо заранее определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии системы. Желательно использовать небольшие, тестируемые единицы кода вместо обширных скриптов. При сбое шага причина должна быть связана с конкретной областью ответственности, а не с запутанной структурой обработки данных.
Подробности усиления безопасности 4/962: измеряйте время выполнения, класс ошибок и расход токенов для данной стадии, затем принимайте решение о сохранении изменений на основе установленного набора критериев, а не на основе устных замечаний.