Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 

Repository files navigation

fundamenta — Conventions

How I write code for web applications, and why.

This is not a standard and not a rulebook. It is the load-bearing structure of what I write — the part that stays the same when the project, the domain and the client change. If you ever end up reading my code, this document is the answer to "why is it like this?". Or you might adopt it.


Philosophy

Every choice serves clarity, security and maintainability. No magic, no voodoo programming. The code does what it says, and says what it does. One class, one responsibility. One model, one entity. One endpoint, one resource.

Every principle below I chased by instinct and mostly learned by getting it wrong.

Principles

  • KISS (Keep It Simple, Stupid) — Always the simplest solution that works. Complexity is the enemy of understanding, and projects get complicated on their own without help.
  • DRY (Don't Repeat Yourself) — Every piece of knowledge has a single, unambiguous representation. Duplication is not inelegant, it diverges: the two copies start identical, one gets fixed and the other does not, and nobody notices until somebody comes through the wrong door.
  • YAGNI (You Aren't Gonna Need It) — Solve today's problem today. The complexity you add now is paid for by somebody else, every day, for the life of the system.
  • SoC (Separation of Concerns) — Each part has one job. Model queries, API changes, View displays. No God Objects.
  • Least Surprise — Code behaves the way the reader expects. No hidden side effects, no function that quietly does a second thing beyond what its name promises. A name that lies is a defect, even when the program works.
  • Fail Fast — When something goes wrong, stop. Never drag an error through the code hoping it will resolve itself.
  • Fail Safe — When you fail, fail securely. Never leave the system in an inconsistent or unsafe state.
  • Errors never pass silently — A read that fails and returns "no results" turns an error into a fact: the queue is empty, the search found nothing. It is the worst kind of lie, because nothing looks wrong — a loud error gets investigated, an empty list gets believed. Silence is acceptable only when it is a choice you can give a reason for — a declared void is a choice — never when it is simply the behaviour that was left there.
  • Know which way each control fails — Every defence, when it breaks, blocks everything or lets everything through. There is no neutral option, only a deliberate one. A brake that seizes does more damage than what it prevents; an access control that switches itself off when its backing service is down was never a control. Same question, opposite answers, both right in their own context. What is never acceptable is not having asked, and discovering the answer on the day it gives way.
  • Capabilities, not ranks — Ask "may this user do this thing?", never "what rank are they?". A linear hierarchy of roles forces you to lie the moment reality produces somebody who may do A but not B while their "superior" may do B but not A.
  • Separate what you defend from what you endure — Half of what looks like an architectural choice is a constraint you did not choose: a machine nobody will upgrade, a format somebody else controls, a deadline. The other half is a position. Keep two lists; mixing them makes the document lie about the reasons.
  • Böhm–Jacopini (1966) — Sequence, selection and iteration are enough. That is a theorem, not an opinion. No jumping out of the structure, no cleverness the reader has to execute in their head to find out where it lands. When a function takes on a shape that would need a diagram to explain, give one of its parts a name — do not add a label. This is not an argument against guard clauses. A flat run of preconditions at the top — "who may be here, what must be true" — followed by the real work is exactly what reads like prose. The defect the rule targets is the nested exit: the return buried inside the third if of a loop, where the reader can no longer tell which paths reach the end. Guards at the top: yes. Exits in the middle: split it.

Stack

At the time of writing, these are the tools of the trade I use. Nothing that follows depends on this table: change the language, change the database, the structure stays the same.

Layer What I use Notes
Backend PHP 8.x (pure) No frameworks
Frontend JavaScript (pure) No frameworks, no bundler
Markup HTML (pure) No template engines
Style CSS (pure) No preprocessors
Database MariaDB / SQLite Prepared statements, no ORM
API REST over HTTP Learn HTTP and curl before the rest

Architecture

Directory structure

public/                  <- the only directory the web server serves
  index.php              <- front controller (single entry point)
  css/  js/  icons/      <- static assets
  api/                   <- endpoints that change state
  pages/                 <- page handlers, reached through the router only
src/                     <- application classes, outside the docroot
  Model/                 <- one model per entity
includes/                <- shared partials, bootstrap
tests/                   <- automated tests
scripts/                 <- CLI maintenance
sql/                     <- service directories: not used by the application,
  migrations/               used by us. Schema history, in order;
  ddl/                      structure (tables, views, indexes);
  dml/                      service data and corrections;
  dql/                      verification queries, written by hand.

Everything outside public/ is physically unreachable over HTTP. Source code, dependencies, configuration and logs are not "protected" by a rule someone can delete — they are somewhere the web server cannot look.

One door

Every request enters through a single point, and the route table lives in one file you can read end to end in a single screen. If finding out which URLs exist means grepping the project, the project has already started getting away from you.

Browser -> web server -> public/index.php (router)
  -> bootstrap (constants, session, auth)
  -> public/pages/*.php (handler)  or  public/api/*.php (endpoint)

Pages display, APIs change

A hard separation with no exceptions: a page renders and mutates nothing; every change goes through an API call. No form POSTs, no actions hidden inside a view.

The gain is not REST elegance. It is that there is exactly one list of places where state can change. That list can be read, counted and reviewed one entry at a time. With mutations scattered across views, the list does not exist, and nobody can claim to have examined all of it.

Model — SQL lives in one place

One model per entity, the connection injected through the constructor, returning plain data. It does not touch the session, does not know about HTTP, does not produce HTML.

class Entity
    __construct(db)
    getAll()      getById(id)      exists(name)
    insert(data)  update(id, data) delete(id)

A question about the data then has exactly one place where it can be answered, and a schema change has exactly one place to propagate from. It also makes the models the part of the system you can test without inventing half a world around them. Junction and detail tables are managed by their parent model.

API — the action lives in the URL

One endpoint per entity. Dispatch on HTTP method and URL shape — never on an action field in the body. Workflow verbs become sub-resources:

GET    /api/entities
GET    /api/entities/{id}
POST   /api/entities
POST   /api/entities/{id}/send            # workflow verb
PUT    /api/entities/{id}/items/{item_id} # nested update
DELETE /api/entities/{id}

Response: { "success": true, "data": … } or { "success": false, "error": "message" }. Status codes mean what they mean: 200, 201, 400, 401, 403, 404, 405, 409. Self-evident, no?

Bound parameters, always, no shortcuts

Never concatenate input into a query — not even "it's just a number". Better still: make the data layer refuse a malformed query instead of trusting the discipline of whoever wrote it. Discipline has bad days. A runtime check does not.

The same applies to writes: a delete or an update without a WHERE clause is refused by the layer, not remembered by the author.

Who you are, before what you can do

A password is not stored, neither in clear nor encrypted: what is stored is its fingerprint, computed by a deliberately slow function with a random seed that differs for every user (Argon2id, or bcrypt failing that). Slowness here is the requirement, not a defect: it is what makes trying billions of passwords one after another impractical. The seed — the salt — means two people with the same password get different fingerprints, and it scraps precomputed fingerprint tables. There is no way back from the fingerprint: at login it is recomputed and compared, and not even the system knows the password in clear.

A failed login never says which of the two halves was wrong, the username or the password. Attempts are counted per account and per address, with a delay that grows on every failure.

The session is regenerated at login and at every change of privilege; the cookie is HttpOnly, Secure, SameSite, and expires twice: on inactivity and in absolute terms.

The second factor (TOTP, RFC 6238) is mandatory for anyone who can change other people's permissions or take data out, and offered to everybody else. TOTP and not SMS: a phone number can be taken away with a SIM swap, and the carrier does not work for you. The secret is stored encrypted, the recovery codes are hashed like passwords and are good for one use.

Be careful about what it protects: the second factor defends the credential, not the session. After login it does nothing at all — if the risk is a stolen session, the defence is rotation and expiry, not another code.

And here the question from the principles comes back: which way does it fail? If the second-factor service does not answer, do people get in or not? There is no right answer, there is the written one, decided in advance, with the compensation stated — a recovery code, an out-of-band procedure. A second factor that switches itself off when its service is down was never a control: it was a label.

Permission is checked twice

The route declares who may pass, and the endpoint checks again on its own. It looks redundant and it is not: the day something reaches that file by another path — a rewrite rule, a file served directly, a refactor of the route table — the second check is the only one left.

Authentication comes before authorisation. "May this user do X?" is not a question about whether there is a user at all. A role check that runs without an auth gate works by coincidence until the day it does not.

Markup in the page, behaviour and style outside

A page carries the HTML skeleton and nothing else: mount points, no function definitions, no inline styles. Behaviour lives in the JavaScript directory, style in the CSS directory.

This is not housekeeping. Code embedded in a page sits outside every gate the project has: the linter does not see it, the style pipeline does not see it, CI does not check it. It is also re-sent on every page view instead of being cached, and server values interpolated into a JavaScript string are an injection seam that HTML escaping does not close.

Server state crosses on a narrow, declared interface: data-* attributes for scalars, or a single non-executable JSON block for structured data — never a server tag inside a function body.

Integrity and courtesy are not the same thing

The optimistic lock is a guarantee: whoever saves sends back the version they loaded, and if it no longer matches, the save is refused. It genuinely prevents two concurrent edits from overwriting each other.

The notice that somebody else has the record open is interface courtesy. It makes life better, it guarantees nothing, and it must never be the only thing standing between your data and a silent overwrite. Confusing the two is tempting, because the courtesy is visible and the guarantee is not.

Traceability is structure, not a feature

If it matters who saw or changed what, that record is designed together with the schema and made unwritable by the application itself — using the database's own mechanisms, not the good intentions of the code. A log the program can rewrite documents only what the program is willing to admit.

Audit belongs in one central table, written by the data layer. Do not scatter log_* columns across rows.

No build step between you and what runs

Where it is possible, what you read is what executes. This is not nostalgia: it is that every transformation between source and execution is one more place for a defect to hide, and one more piece of machinery that has to still work in five years, when nobody remembers how it was set up.

The VT100 rule

Everything needed to install, update, inspect and repair the application must be doable from a character terminal, over a slow link, with no graphics stack. This is not nostalgia either: on a real server there is no desktop, and the day you actually need to get in you are on ssh from somewhere inconvenient, with somebody waiting.

  • Schema and migrations are text files, loaded from the command line and read without a client.
  • Configuration is a text file, not a screen to fill in.
  • Logs are lines: one event per line, stable fields, grep-able. A log you can only read inside a dashboard is a log you will not read at three in the morning.
  • Every maintenance operation has a script that runs without interaction and says on standard output what it did.
  • Documentation is read with less: Markdown, 80 columns, no diagram that only exists inside a browser. If an explanation only stands up with a picture, it is usually the structure underneath that is too complicated.
  • No mandatory tool with a graphical interface. If the only way to do something is to click it, that thing is not repeatable, not automatable and not verifiable.

I put this here although it applies from day one: projects are born by writing the database structure on a blank sheet of paper — trust me, you need nothing else. Then the trials are run strictly from the command line: you write the statements into a text file and load it from there. You need no program like DBeaver or other monsters of the kind, and in the meantime you learn a great deal.

The real reason, though, is another one: the terminal hides nothing from you. A graphical client shows you the result it decided to show you; the terminal shows you what happened.

Not that graphical tools are evil, and they are not forbidden: they are the piano's sustain pedal, the one that — as they say — covers up your mistakes. Use it, knowing that is exactly what it is doing. An interface resting on top of text files is fine, but keep in mind that it may be hiding something from you — and that something is exactly what you want to see. One that becomes mandatory takes away composition, and with composition goes verification.

Programming paradigm

Object-oriented on both sides — classes with clear responsibilities on the server, classes with async/await on the client. Not because OOP is a virtue in itself, but because "one thing, one owner" needs somewhere to live.

Coding standards

Any language

  • Indentation: 4 spaces, no tabs.
  • Classes: PascalCase. Methods and variables: camelCase. Constants: UPPER_SNAKE_CASE.
  • One class per file, and the file is named after the class.
  • Explicit visibility everywhere it exists.
  • Types declared where the language allows it.

JavaScript

  • const or let, never var.
  • async/await with fetch, never raw .then() chains.
  • DOM built with createElement() and textContent — never innerHTML with data that came from outside.
  • Semicolons: always.
  • Two classes per entity: *View for the list, *Form for add/edit.

SQL

  • Keywords UPPERCASE; tables and columns snake_case.
  • Tables plural, primary key {entity}_id, junction tables {parent}_{child}, views v_{entity}.
  • Column order: primary key, then foreign keys, then the entity's own columns, and last — always — a status flag. I call it status and use it for logical deletion: that way you delete nothing, which is also right and proper.
  • Named placeholders (:id) in new code. Positional ones, if in a moment of weakness you used ?, are migrated when you are already touching the query: no conversion-only commits, low value and real risk.
  • Never concatenate user input.
  • A datum is not a number just because it is made of digits. In a laboratory a result arrives as < 0.03, as absent, as traces: the number is one case of the domain, not the domain. The same goes for tax codes, postcodes and staff numbers, where declaring them integers eats the leading zero. Those are text, and the check happens in the code — you will thank me. When you then need to compute or plot, add a numeric column derived from the text: the measure sits next to what the operator actually wrote, never in its place. Keys have nothing to do with this: primary and foreign keys are numeric, always.
  • Validation tables are a godsend: units (unit_id, description, status), methods (method_id, description, status). One row for each thing that repeats, and the rest of the database names it.

HTML and CSS

  • Semantic tags. Attributes in double quotes. IDs and classes in kebab-case.
  • One CSS property per line, component files as the source, generated files never edited by hand.

File naming

  • File names: snake_case, lowercase. Plural for collections, singular for a single entity.
  • Class files match the class name exactly.
  • Deprecated files are moved out of the docroot, never renamed in place. A renamed source file is a source file served as plain text.

Language

  • Code — variables, methods, classes, comments: English.
  • UI labels: the language of the people using it.
  • Comments explain why, not how. The code makes the how self-evident; the reasoning is the one thing it cannot contain. Without it the next person — who two years from now is you — will walk the same road from the start, or "simplify away" a defence without knowing it was one.
  • When a solution has a known and accepted gap, write it where the solution lives and pin it with a test, so it stays a decision on the record instead of becoming a surprise.

Encoding

UTF-8 everywhere: files, database, HTTP headers, HTML meta tags. No BOM.

Security (non-negotiable)

These are rules, not features. They apply from day one.

  • SQL injection — Prepared statements always. Never concatenate user input.
  • XSS — Escape on output, server side and client side. Never innerHTML with user data.
  • Input validation — On the server. Client-side validation is for UX only, never for security.
  • CSRF — Tokens on every state-changing operation.
  • Tenant isolation — If you use this technique, remember that one tenant must never reach another's data. Every query filters by the owning organisation.
  • File security — User files outside the document root, served through an authenticated proxy. Real MIME verified from the content, never from what the client claimed.
  • No path traversal — Block ../ and absolute paths in user input.
  • Audit logging — Record access to sensitive resources.
  • Document root separation — Only the public directory is served.

Authentication is not on this list because it has its own section above, and a rule written in two places is a rule enforced in one.

How I document

Three documents on three different time horizons, because mixing them is the reliable way to keep none of them current.

  • The handover — what just happened, what is still pending, where the traps are. It lives for days, and it is overwritten, never appended: a status document that grows becomes an archive, and nobody reads an archive before starting work.
  • The decisions — the reasoning behind non-obvious choices, in chronological order, appended and never rewritten. It lives as long as the project. It answers "why on earth is it like this?" without requiring you to be in the room.
  • The security posture — the controls in place and, on the same page, the conscious trade-offs: what was accepted, why, what compensates for it, and under what condition it gets revisited.

That last point deserves to be spelled out, because it is the part everyone leaves out: a document that declares only what works is not a security document, it is a brochure. Residual risk written down, with a review date, is what separates a decision that was taken from an oversight not yet discovered. And it protects you: a deliberate, reasoned choice can be defended. An oversight cannot.

One rule over all of them: a document is judged on density, not length. One page where every line is anchored to something true is worth more than six pages of general principles — including these, the day they stop being anchored to anything.

Git

  • Conventional Commits: feat:, fix:, refactor:, docs:, style:, test:, chore:.
  • One logical change per commit.
  • A pre-commit hook checks syntax, style and lint. A rule nobody enforces is a preference.

Versioning

Semantic versioning, MAJOR.MINOR.PATCH, with a single source of truth in the repository that the application reads at runtime — not a number copied into three files.

Component When to bump
MAJOR Breaking change for users or an incompatible migration
MINOR New feature, hardening, no breaking change
PATCH Bug fix, refactoring, documentation

Bump before deploying, once per deploy, and tag the release.


What I refuse to do

Refusals say more than affirmations.

  • No framework where none is needed. A framework is a loan: it advances you time today and asks back a version constraint, an upgrade treadmill and a way of thinking. Sometimes it is worth it. Take it knowing it is debt, not because it is what one does.
  • No silent failures. A swallowed error is an error that comes back later, in disguise. An error converted into a plausible value is worse: the swallowed one leaves nothing behind, the converted one leaves a lie shaped like data.
  • No security theatre. A control that appears to be there and is not is worse than a missing one, because it spends the attention you would have used to look closer. And do not be in a hurry when you think it through.
  • No rule in two places. If it lives in two files, chance decides which one wins. And if you are about to copy a rule into a second file, the problem is not the copy — it is that you have not yet decided who owns it.
  • No document that needs an erratum to be read. If the manual has to say "ignore section three", section three should be deleted.
  • No dependency added to save twenty lines. Every dependency is code you have not read, running with your privileges, maintained on somebody else's schedule.

Where I do not follow this yet

Written down, these read as if I had always done them. I have not — and the day this document stops admitting it is the day it becomes the brochure it warns about above.

Three of them are contradicted by my own older code. I know because I went looking, on purpose, before publishing this.

  • Permissions as ranks. Older code of mine compares numbers — role ≤ 3, role < currentRole. It holds up until reality produces somebody who may do A but not B while their "superior" may do B but not A, and from then on every exception is another condition bolted on. Only the newest of my codebases asks about capabilities. That is a direction, not a history.
  • SQL outside the data layer. In the oldest one, queries live inside the screens that display their results. Every schema change becomes a hunt, and nothing can be tested without building half a world around it.
  • Mutations from a page. A form POST handled by a page, sitting among pages that only render. One exception is enough to lose the property that made the rule worth having — that the list of places where state can change can be read to the end.

None of this is on a schedule, and I am not promising to fix it. It is written here because a principle nobody has ever paid for is only a preference, and because anyone reading my code deserves to know which parts I would defend and which parts I simply have not got to.

And finally: let us remember to keep learning, to keep getting better, and to keep a certain openness of mind, always.


Giuseppe Costanzi — github.com/1966bc

About

How I build software, and why — principles, the shape of a web application, and the refusals

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors