|
| 1 | +# Code Guidelines |
| 2 | + |
| 3 | +# Language specific |
| 4 | + |
| 5 | +[Rust code guidelines](rust-code-guidelines.md) |
| 6 | + |
| 7 | +# Make your services as resilient to errors as possible |
| 8 | + |
| 9 | +- Perform more benchmarking/add benchmarking tests through our codebase. Currently |
| 10 | +there are portions of the codebase that we have unknown performance for that |
| 11 | +may become more important as we scale. Most languages have benchmark test |
| 12 | +capability, rust has it built in for example. |
| 13 | +- Implement error recovery even for unlikely cases. Think about how the service can continue to work after different failures. |
| 14 | +- The service should continue to work (if possible) if its dependencies are unavailable or broken. |
| 15 | +- Avoid the possibility of leaving the service in a state where it no longer able to start or work properly. The service should be able to recover from things like invalid files or unexpected database state. If that is not possible, provide clear error messages that explain what should be done to fix it. |
| 16 | +- Minimize the number of dependencies required for a service to start. |
| 17 | +- It should be possible to run multiple instances of each service at the same time. |
| 18 | + |
| 19 | +# Set up essential tooling |
| 20 | + |
| 21 | +- Use strongest lint settings. It is better to have at minimum pedantic warnings |
| 22 | +on all projects. Good examples of bad settings: allowing `any` globally in |
| 23 | +typescript, ignoring integer clippy type warnings in Rust, etc. |
| 24 | +- Add extensive logging, metrics and tracing capability early, much of our code is missing |
| 25 | +metrics, good log handling, or ability to do introspection on code that has |
| 26 | +failed in retrospect. Good example: hermes launch. |
| 27 | + |
| 28 | +# Keep the code readable and maintainable |
| 29 | + |
| 30 | +- Make heavy use of types to define behaviour. In general introducing a type can be |
| 31 | +thought of as introducing a unit test. For example: |
| 32 | + |
| 33 | + ```rust |
| 34 | + struct PositiveTime(i64); |
| 35 | + |
| 36 | + impl TryFrom<i64> for PositiveTime { |
| 37 | + type Err = (); |
| 38 | + fn try_from(n: i64) -> Result<Self, Self::Err> { |
| 39 | + if n < 0 { |
| 40 | + return Err(()); |
| 41 | + } |
| 42 | + return Ok(Self(n)); |
| 43 | + } |
| 44 | + } |
| 45 | + |
| 46 | + ``` |
| 47 | + |
| 48 | + This can be thought of reducing the valid range of i64 to one we prefer |
| 49 | + (given that i64 is the native Linux time type but often we do not want these) |
| 50 | + that we can enforce a compile-time. The benefit in types over unit tests is |
| 51 | + simply use-at-site of a type ensure behaviour everywhere and reducing the |
| 52 | + amount of unwanted behaviour in a codebase. |
| 53 | + |
| 54 | + Currently we do not try hard enough to isolate behaviours through types. |
| 55 | + |
| 56 | +- Avoid monolithic event handlers, and avoid state handling in logic. Some |
| 57 | +stateful code in our repos mixes the logic handling with the state handle |
| 58 | +code which produces very long, hard to reason about code which ends up as |
| 59 | +a rather large inline state machine: |
| 60 | + |
| 61 | + Good: |
| 62 | + |
| 63 | + ```tsx |
| 64 | + function handleEvent(e, state) { |
| 65 | + switch(e.type) { |
| 66 | + case Event.Websocket: handleWebsocketEvent(e, state.websockets); |
| 67 | + case Event.PythNet: handlePythnetEvent(e, state.pyth_handle); |
| 68 | + case ... |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + ``` |
| 73 | + |
| 74 | + Bad: |
| 75 | + |
| 76 | + ```tsx |
| 77 | + function handleEvent(e) { |
| 78 | + // Many inlined state tracking vars. Not much better than globals. |
| 79 | + var latstPythNetupdateTime = DateTime.now(); |
| 80 | + var clientsWaiting = {}; |
| 81 | + var ... |
| 82 | + |
| 83 | + switch(e.type) { |
| 84 | + // lots of inline handling |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + ``` |
| 89 | + |
| 90 | +- Avoid catch-all modules, I.E: `types/`, `utils/` |
| 91 | +- Favor Immutability and Idempotency. Both are a huge source of reducing logic bugs. |
| 92 | +- State should whenever possible flow top-down, I.E: create at entry point and |
| 93 | +flow to other components. Global state should be avoided and no state should be |
| 94 | +hidden in separate modules. |
| 95 | + |
| 96 | + Good: |
| 97 | + |
| 98 | + ```tsx |
| 99 | + // main.ts |
| 100 | + function main() { |
| 101 | + const db = db.init(); |
| 102 | + initDb(db); |
| 103 | + } |
| 104 | + |
| 105 | + ``` |
| 106 | + |
| 107 | + Bad: |
| 108 | + |
| 109 | + ```tsx |
| 110 | + // main.ts |
| 111 | + const { db } = require('db'); |
| 112 | + function() { |
| 113 | + initDb(); // Databaes not passed, implies global use. |
| 114 | + } |
| 115 | + |
| 116 | + ``` |
| 117 | + |
| 118 | +- For types/functions that are only used once, keep them close to the |
| 119 | +definition. If they are re-used, try and lift them only up to a common |
| 120 | +parent, in the following example types/functions only lift as far |
| 121 | +as they are useful: |
| 122 | + |
| 123 | + Example File Hierarchy: |
| 124 | + |
| 125 | + ``` |
| 126 | + lib/routes.rs:validateUserId() |
| 127 | + lib/routes/user.rs:type RequestUser |
| 128 | + lib/routes/user/register.rs:generateRandomUsername() |
| 129 | + |
| 130 | + ``` |
| 131 | + |
| 132 | + Good: |
| 133 | + |
| 134 | + ```tsx |
| 135 | + // Definition only applies to this function, keep locality. |
| 136 | + type FeedResponse = { |
| 137 | + id: FeedId, |
| 138 | + feed: Feed, |
| 139 | + }; |
| 140 | + |
| 141 | + // Note the distinction between FeedResponse/Feed for DDD. |
| 142 | + function getFeed(id: FeedId, db: Db): FeedResponse { |
| 143 | + let feed: Feed = db.execute(FEED_QUERY, [id]); |
| 144 | + return { id, feed: feed, } |
| 145 | + } |
| 146 | + |
| 147 | + ``` |
| 148 | + |
| 149 | + Bad: |
| 150 | + |
| 151 | + ```tsx |
| 152 | + import { FeedResponse } from 'types'; |
| 153 | + function getFeed(id: FeedId, db: Db): FeedResponse { |
| 154 | + let feed = db.execute(FEED_QUERY, [id]); |
| 155 | + return { id, feed: feed, } |
| 156 | + } |
| 157 | + |
| 158 | + ``` |
| 159 | + |
| 160 | +- Map functionality into submodules when a module defines a category of handlers. |
| 161 | +This help emphasise where code re-use should happen, for example: |
| 162 | + |
| 163 | + Good: |
| 164 | + |
| 165 | + ``` |
| 166 | + src/routes/user/register.ts |
| 167 | + src/routes/user/login.ts |
| 168 | + src/routes/user/add_role.ts |
| 169 | + src/routes/index.ts |
| 170 | + |
| 171 | + ``` |
| 172 | + |
| 173 | + Bad: |
| 174 | + |
| 175 | + ```tsx |
| 176 | + // src/index.ts |
| 177 | + function register() { ... } |
| 178 | + function login() { ... } |
| 179 | + function addRole() { ... } |
| 180 | + function index() { ... } |
| 181 | + |
| 182 | + ``` |
| 183 | + |
| 184 | + Not only does this make large unwieldy files but it encourages things like |
| 185 | + `types/` catch alls, or unnecessary sharing of functionality. For example |
| 186 | + imagine a `usernameAsBase58` function thrown into this file, that then |
| 187 | + looks useful within an unrelated to users function, it can be tempting to |
| 188 | + abuse the utility function or move it to a vague catch-all location. Focus |
| 189 | + on clear, API boundaries even within our own codebase. |
| 190 | + |
| 191 | +- When possible use layered architecture (onion/hexagonal/domain driven design) where |
| 192 | +we separate API processing, business logic, and data logic. The benefit of this |
| 193 | +is it defines API layers within the application itself: |
| 194 | + |
| 195 | + Good: |
| 196 | + |
| 197 | + ```tsx |
| 198 | + // web/user/register.ts |
| 199 | + import { registerUser, User } from 'api/user/register.ts'; |
| 200 | + |
| 201 | + // Note locality: one place use functions stay near, no utils/ |
| 202 | + function verifyUsername( ... |
| 203 | + function verifyPassword( ... |
| 204 | + |
| 205 | + // Locality again. |
| 206 | + type RegisterRequest = { |
| 207 | + ... |
| 208 | + }; |
| 209 | + |
| 210 | + function register(req: RegisterRequest): void { |
| 211 | + // Validation Logic Only |
| 212 | + verifyUsername(req.username); |
| 213 | + verifyPassword(req.password); |
| 214 | + |
| 215 | + // Business Logic Separate |
| 216 | + registerUser({ |
| 217 | + username: req.username, |
| 218 | + password: req.password, |
| 219 | + }); |
| 220 | + } |
| 221 | + |
| 222 | + ``` |
| 223 | + |
| 224 | + ```tsx |
| 225 | + // api/user/register.ts |
| 226 | + import { storeUser, DbUser } from 'db/user'; |
| 227 | + |
| 228 | + function registerUser(user: User) { |
| 229 | + const user = fetchByUsername(user.username); |
| 230 | + if (user) { |
| 231 | + throw "User Exists; |
| 232 | + } |
| 233 | + |
| 234 | + // Note again that the type used here differs from User (DbUser) which |
| 235 | + // prevents code breakage (such as if the schema is updated but the |
| 236 | + // code is not. |
| 237 | + storeUser({ |
| 238 | + username: user.username, |
| 239 | + password: hash(user.password), |
| 240 | + }); |
| 241 | + } |
| 242 | + |
| 243 | + ``` |
0 commit comments