Rethinking “Pixel Perfect” Web Design

It’s 2026. We are operating in an era of incredible technological leaps, where advanced tooling and AI-enhanced workflows have fundamentally transformed how we design, build, and bridge the gap between the two. The web is moving faster than ever, with groundbreaking features and standards emerging almost daily.

Yet, in the middle of this high-speed evolution, there’s one thing we’ve been carrying with us since the early days of print, a phrase that feels increasingly out of sync with our modern reality: “Pixel Perfect.”

I’ll be honest, I’m not a fan. In fact, I believe the idea that we can have pixel-perfection in our designs has become misleading, vague, and ultimately counterproductive to the way we build for the modern web. As a community of developers and designers, it’s time we take a hard look at this legacy concept, understand why it’s failing us, and redefine what “perfection” actually looks like in a multi-device, fluid world.

A Brief History Of A Rigid Mindset

To understand why many of us still aim for pixel perfection today, we have to look back at where it all began. It didn’t start on the web, but as a stowaway from the era when layout software first allowed us to design for print on a personal computer, and GUI design from the late 1980s and ’90s.

In the print industry, perfection was absolute. Once a design was sent to the press, every dot of ink had a fixed, unchangeable position on a physical page. When designers transitioned to the early web, they brought this “printed page” mentality with them. The goal was simple: The website must be an exact, pixel-for-pixel replica of the static mockup created in design applications like Photoshop and QuarkXPress.

I’m old enough to remember working with talented designers who had spent their entire careers in the print world. They would hand over web designs and, with total sincerity, insist on discussing the layout in centimeters and inches. To them, the screen was just another piece of paper, albeit one that glowed.

In those days, we “tamed” the web to achieve this. We used table-based layouts, nested three levels deep, and stretched 1×1 pixel “spacer GIFs” to create precise gaps. We designed for a single, “standard” resolution (usually 800×600) because, back then, we could actually pretend we knew exactly what the user was seeing.

<!-- A typical "Pixel Perfect" layout from 1998 -->
<table width="800" border="0" cellpadding="0" cellspacing="0">
  <tr>
    <td width="150" valign="top" bgcolor="#CCCCCC">
      <img src="spacer.gif" width="150" height="1"> <!-- Sidebar -->
    </td>
    <td width="10"><img src="spacer.gif" width="10" height="1"></td>
    <td width="640" valign="top">
      <!-- Content goes here -->
    </td>
  </tr>
</table>

Cracks In The Foundation

The first major challenge to the fixed-table mindset came as early as 2000. In his seminal article, “A Dao of Web Design”, John Allsopp argued that by trying to force the web into the constraints of print, we were missing the point of the medium entirely. He called the quest for pixel-perfection a “ritual” that ignored the web’s inherent fluidity.

When a new medium borrows from an existing one, some of what it borrows makes sense, but much of the borrowing is thoughtless, “ritual,” and often constrains the new medium. Over time, the new medium develops its own conventions, throwing off existing conventions that don’t make sense.

Nonetheless, the “pixel-perfection” refused to die. While its meaning has shifted and morphed over the decades, it has rarely been well-defined. Many have tried, such as in 2010 when the design agency ustwo released the Pixel Perfect Precision (PPP) (PDF) handbook. But that same year, Responsive Web Design also gained massive momentum, effectively killing the idea that a website could look identical on every screen.

Yet, here we are, still using a term born from the limitations of monitors dated to the ’90s to describe the complex interfaces of 2026.

Note: Before we continue, it’s important to acknowledge the exceptions. There are, of course, scenarios where pixel precision is non-negotiable. Icon grids, sprite sheets, canvas rendering, game engines, or bitmap exports often require exact, pixel-level control to function correctly. These, however, are specialized technical requirements, not a general rule for modern UI development.

Why “Pixel Perfect” Is Failing the Modern Web

In our current landscape, clinging to the idea of “pixel perfection” isn’t just anachronistic, it’s actively harmful to the products we build. Here is why.

It Is Fundamentally Vague

Let’s start with a simple question: When a designer asks for a “pixel-perfect” implementation, what are they actually asking for? Is it the colors, the spacing, the typography, the borders, the alignment, the shadows, the interactions? Take a moment to think about it.

If your answer is “everything”, then you’ve just identified the core issue.

The term “pixel-perfect” is so all-encompassing that it lacks any real technical specificity. It’s a blanket statement that masks a lack of clear requirements. When we say “make it pixel perfect,” we aren’t giving a directive; we’re expressing a feeling.

The Multi-Surface Reality

The concept of a “standard screen size” is now a relic of the past. We are building for an almost infinite variety of viewports, resolutions, and aspect-ratios, and this reality is not likely to change any time soon. Plus, the web is no longer confined to a flat, rectangular piece of glass; it can be on a foldable phone that changes aspect ratios mid-session, or on a spatial interface projected into a room.

Every Internet-connected device has its own pixel density, scaling factors, and rendering quirks.

A design that is “perfect” on one set of pixels is, by definition, imperfect on another. Striving for a single, static “perfection” ignores the fluid, adaptive nature of the modern web. When the canvas is constantly shifting, the very idea of a fixed pixel implementation becomes a technical impossibility.

The Dynamic Nature Of Content

A static mockup is a snapshot of a single state with a specific set of data. But content is rarely static like that in the real world. Localization is a prime example: a label that fits perfectly inside a button component in English might overflow the container in German or require a different font entirely for CJK languages.

Beyond text length, localization means changes with currency symbols, date formatting, and numeric systems. Any of these variables can significantly impact a page layout. If a design is built to be “pixel-perfect” based on a specific string of text, it is inherently fragile. A pixel-perfect layout completely collapses the moment content changes.

Accessibility Is The Real Perfection

True perfection means a site that works for everyone. If a layout is so rigid that it breaks when a user increases their font size or forces a high-contrast mode, it isn’t perfect — it’s broken. “Pixel perfect” often prioritizes visual aesthetics over functional accessibility, creating barriers for users who don’t fit the “standard” profile.

Think Systems, Not Pages

We no longer build pages; we build design systems. We create components that must work in isolation and a variety of contexts, whether in headers, in sidebars, or in dynamic grids. Trying to match a component to a specific pixel coordinate in a static mockup is a fool’s errand.

A pure “pixel-perfect” approach treats every instance as a unique snowflake, which is the antithesis of a scalable, component-based architecture. It forces developers to choose between following a static image and maintaining the integrity of the system.

Perfection Is Technical Debt

When we prioritize exact visual matching over sound engineering, we aren’t just making a design choice; we are incurring technical debt. Chasing that last pixel often forces developers to bypass the browser’s natural layout engine.

Working in exact units leads to “magic numbers”, those arbitrary margin-top: 3px or left: -1px hacks, sprinkled throughout the codebase to force an element into a specific position on a specific screen. This creates a fragile, brittle architecture, leading to a never-ending cycle of “visual bug” tickets.

/* The "Pixel Perfect" Hack */
.card-title {
  margin-top: 13px; /* Matches the mockup exactly on 1440px */
  margin-left: -2px; /* Optical adjustment for a specific font */
}
/* The "Design Intent" Solution */
.card-title {
  margin-top: var(--space-m); /* Part of a consistent scale */
  align-self: start; /* Logical alignment */
}

By insisting on pixel-perfection, we are building a foundation that is difficult to automate, difficult to refactor, and ultimately, more expensive to maintain. We have much more flexible ways to calculate sizing in CSS, thanks to relative units.

Moving From Pixels To Intent

So far, I’ve spent a lot of time talking about what we shouldn’t do. But let’s be clear: Moving away from “pixel perfection” isn’t an excuse for sloppy implementation or a “close enough” attitude. We still need consistency, we still want our products to look and feel high-quality, and we still need a shared methodology for achieving that.

So, if “pixel perfection” is no longer a viable goal, what should we be striving for?

The answer, I believe, lies in shifting our focus from individual pixels to design intent. In a fluid world, perfection isn’t about matching a static image, but ensuring that the core logic and visual integrity of the design are preserved across every possible context.

Design Intent Over Static Values

Instead of asking for a margin: 24px in a design, we should be asking: Why is this margin here? Is it to create a visual separation between sections? Is it part of a consistent spacing scale? When we understand the intent, we can implement it using fluid units and functions (like rem and clamp(), respectively) and use advanced tools, like CSS Container Queries, that allow the design to breathe and adapt while still feeling “right”.

/* Intent: A heading that scales smoothly with the viewport */
h1 {
  font-size: clamp(2rem, 5vw + 1rem, 4rem);
}
/* Intent: Change layout based on the component's own width, not the screen */
.card-container {
  container-type: inline-size;
}
@container (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 1fr 2fr;
  }
}

Speaking In Tokens

Design tokens are the bridge between design and code. When a designer and developer agree on a token like --spacing-large instead of 32px, they aren’t just syncing values, but instead syncing logic. This ensures that even if the underlying value changes to accommodate a specific condition, the relationship between elements remains perfect.

:root {
  /* The logic is defined once */
  --color-primary: #007bff;
  --spacing-unit: 8px;
  --spacing-large: calc(var(--spacing-unit) * 4);
}

/* And reused everwhere */
.button {
  background-color: var(--color-primary);
  padding: var(--spacing-large);
}

Fluidity As A Feature, Not A Bug

We need to stop viewing the web’s flexibility as something to be tamed and start seeing that flexibility as its greatest strength. A “perfect” implementation is one that looks intentional at 320px, 1280px, and even in a 3D spatial environment. This means embracing intrinsic web design based on an element’s natural size in any context — and using modern CSS tools to create layouts that “know” how to arrange themselves based on the available space.

Death To The “Handover”

In this intent-driven world, the “handover” of traditional design assets has become another relic of the past. We no longer pass static Photoshop files across a digital wall and hope for the best. Instead, we work within living design systems.

Modern tooling allows designers to specify behaviors, not just positions. When a designer defines a component, they aren’t just drawing a box; they’re defining its constraints, its fluid scales, and its relationship to the content. As developers, our job is to implement that logic.

The conversation has shifted from “Why is this three pixels off?” to “How should this component behave when the container shrinks?” and “What happens to the hierarchy when the text is translated to a longer language?”

Better Language, Better Outcomes

Speaking of conversations, when we aim for “pixel perfection”, we set ourselves up for friction. Mature teams have long moved past this binary “match-or-fail” mindset towards a more descriptive vocabulary that reflects the complexity of our work.

By replacing “pixel perfect” with more precise terms, we create shared expectations and eliminate pointless arguments. Here are a few phrases that have served me well for productive discussions around intent and fluidity:

  • “Visually consistent with the design system.”
    Instead of matching a specific mockup, we ensure the implementation follows the established rules of our system.
  • “Matches spacing and hierarchy.”
    We focus on the relationships and rhythm between elements rather than their absolute coordinates.
  • “Preserves proportions and alignment logic.”
    We ensure that the intent of the layout remains intact, even as it scales and shifts.
  • “Acceptable variance across platforms.”
    We acknowledge that a site will look different, within a defined and agreed-upon range of variation, and that’s okay as long as the experience remains high-quality.

Language creates reality. Clear language doesn’t just improve the code, but the relationship between designers and developers. It moves us toward a shared ownership of the final, living product. When we speak the same language, “perfection” stops being a demand and starts being a collaborative achievement.

A Note To My Design Colleagues

When you hand over a design, don’t give us a fixed width, but a set of rules. Tell us what should stretch, what should stay fixed, and what should happen when the content inevitably overflows. Your “perfection” lies in the logic you define, not the pixels you draw.

The New Standard Of Excellence

The web was never meant to be a static gallery of frozen pixels. It was born to be a messy, fluid, and gloriously unpredictable medium. When we cling to an outdated model of “pixel perfection”, we are effectively trying to put a leash on a hurricane. It’s unnatural in today’s front-end landscape.

In 2026, we have the tools to build interfaces that think, adapt, and breathe. We have AI that can generate layouts in seconds and spatial interfaces that defy the very concept of a “screen”. In this world, perfection isn’t a fixed coordinate but a promise; it’s the promise that no matter who is looking, or what they are looking through, the soul of the design remains intact.

So, let’s bury the term once and for all. Let’s leave the centimeters to the architects and the spacer GIFs to the digital museums. If you want something to look exactly the same for the next hundred years, carve it in stone or print it on a high-quality cardstock. But if you want to build for the web, embrace the chaos.

Stop counting pixels. Start building intent.

Named Query Filters in .NET 10

From Hidden Magic to Clean, Explicit Architecture

Since their introduction in EF Core, Global Query Filters have offered a powerful idea:
define cross-cutting query rules once, and apply them automatically everywhere.
In theory, this reduces repetition and enforces consistency.
In practice, however, real-world usage exposed an important issue:
lack of clarity.

example :

public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.HasQueryFilter(u => u.IsDeleted == false);
        builder.ToTable(nameof(User));
    }
}

The Problem with Global Query Filters :
While useful, traditional Global Query Filters came with well-known drawbacks:

  • Query behavior was not obvious when reading the code
  • Filters were hidden inside model configuration
  • Debugging missing data could be confusing
  • New team members often had no idea why certain rows were excluded

In short, the rules existed — but they were unnamed and implicit.

Enter Named Query Filters in .NET 10

With Named Query Filters, .NET 10 doesn’t change the core concept it improves how the concept is expressed.
Instead of silent, invisible filters, we now have:

  • Explicit names
  • Clear intent
  • Self-documenting rules

example :

public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.HasQueryFilter(UserFilters.SoftDelete,
                               u => u.IsDeleted == false);
        builder.ToTable(nameof(User));
    }
}

This is not just a cosmetic improvement; it’s an architectural one.

Why Naming Matters

In software design, naming is a first-class design decision.
When a query filter has a name:

  • Code becomes easier to read
  • Business rules become explicit
  • Hidden behavior is reduced
  • Team communication improves

This aligns naturally with:

  • Clean Architecture
  • Domain-Driven Design
  • The Principle of Least Surprise

Using Query Filters as Design, Not a Trick

Query Filters — especially in their named form — work best when they represent
system-wide, stable rules, such as:

  • Soft Delete
  • Multi-Tenancy
  • Data Isolation
  • Global Security Constraints

Logic that changes per screen or use case should still live inside the query itself.

What Actually Improved?

With Named Query Filters:

  • Rules are no longer hidden
  • Query behavior is easier to understand
  • Debugging becomes simpler
  • Maintenance improves
  • Architectural intent is clearer

Final Thoughts

Named Query Filters in .NET 10 demonstrate a subtle
but important shift:
moving from implicit behavior to explicit design. Small features like this don’t just clean up code , they improve how teams reason about systems.And that’s where real productivity gains come from.

Async Reactivity with Angular Resources — A Production‑Minded Guide (2026)

Async Reactivity with Angular Resources — A Production‑Minded Guide (2026)

Async Reactivity with Angular Resources — A Production‑Minded Guide (2026)

Signals are synchronous by design (signal, computed, effect, input).

But real apps aren’t. They fetch data, debounce queries, cancel requests, retry, and render loading/error states.

Resource is Angular’s answer: async data that plugs into signal-based code while keeping template reads synchronous.

⚠️ Important: resource is currently experimental. It’s ready to try, but the API may change before it’s stable.

Table of Contents

  • The Problem Resource Solves
  • Core Mental Model
  • Resource Anatomy: params + loader
  • Resource Status: build UIs without guesswork
  • Cancellation (AbortSignal) done right
  • Reloading: deterministic refresh
  • Patterns You’ll Use in Production

    • 1) Parameterized fetch (route/query)
    • 2) Search with debounce + cancellation
    • 3) Optimistic local state: local status
    • 4) Error boundaries + fallbacks
  • httpResource: HttpClient as signals
  • Resource vs RxJS vs rxResource
  • Testing Resources
  • SSR notes and PendingTasks
  • Pitfalls & Anti‑Patterns
  • Upgrade & Safety Checklist
  • Conclusion

The Problem Resource Solves

Traditional Angular async patterns typically look like:

  • Observable pipelines (RxJS)
  • async pipes in templates
  • manual subscription management in services/components
  • “loading” booleans scattered across state

Resources centralize the async lifecycle into a single ref, while exposing:

  • value() (signal)
  • status() (signal)
  • isLoading() (signal)
  • error() (signal)
  • hasValue() (type guard)

Result: UI code becomes deterministic.

Core Mental Model

Think of a Resource as:

  1. A reactive input: params()
  2. A side-effectful async function: loader({ params, previous, abortSignal })
  3. A signal output: value() plus status signals

It behaves like a state machine:

idle → loading → resolved
   ↘︎ error
resolved → reloading → resolved
resolved → local (if you set/update locally)

Resource Anatomy: params + loader

The simplest form uses resource({...}):

import { resource, computed, Signal } from '@angular/core';

const userId: Signal<string> = getUserId();

const userResource = resource({
  params: () => ({ id: userId() }),
  loader: ({ params }) => fetchUser(params),
});

const firstName = computed(() => {
  if (userResource.hasValue()) {
    return userResource.value().firstName;
  }
  return undefined;
});

What params() really means

  • params is a reactive computation
  • every signal read inside it becomes a dependency
  • when any dependency changes, Angular recomputes params and triggers a (new) load

If params() returns undefined, the loader will not run and the resource becomes idle.

Resource Status: build UIs without guesswork

Resources provide status signals:

  • value() → last resolved value (or undefined)
  • hasValue() → type guard + safe read check
  • error() → last error (or undefined)
  • isLoading() → whether loader is running
  • status() → fine-grained state machine string

Status values:

Status value() Meaning
idle undefined No valid params; loader hasn’t run
loading undefined Loader running due to params change
reloading previous value Loader running because .reload()
resolved resolved value Loader completed successfully
error undefined Loader threw/failed
local locally set value You used .set() / .update()

Template pattern (clean and predictable)

@if (user.status() === 'loading') {
  <app-skeleton />
} @else if (user.status() === 'error') {
  <app-error [error]="user.error()" />
} @else if (user.hasValue()) {
  <app-user-card [user]="user.value()" />
} @else {
  <p>No user loaded.</p>
}

This avoids “undefined gymnastics” and makes UI behavior explicit.

Cancellation (AbortSignal) done right

A resource aborts outstanding loads if params() changes mid-flight.

Angular exposes an AbortSignal:

const userResource = resource({
  params: () => ({ id: userId() }),
  loader: ({ params, abortSignal }): Promise<User> => {
    return fetch(`/users/${params.id}`, { signal: abortSignal })
      .then(r => r.json());
  },
});

Why you should care (production)

Cancellation prevents:

  • race conditions (slow response overwriting the newest)
  • wasted bandwidth
  • stale UI updates
  • “double renders” after rapid navigation or input changes

Reloading: deterministic refresh

Resources can be refreshed without changing parameters:

userResource.reload();

Key detail: reload transitions to reloading (preserving value()), which is ideal for:

  • background refresh
  • pull-to-refresh
  • “Retry” buttons that don’t wipe the screen

Patterns You’ll Use in Production

1) Parameterized fetch (route/query)

import { resource } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs/operators';

const route = inject(ActivatedRoute);

// Convert paramMap observable to signal
const userId = toSignal(
  route.paramMap.pipe(map(m => m.get('id') ?? '')),
  { initialValue: '' }
);

const user = resource({
  params: () => userId() ? ({ id: userId() }) : undefined,
  loader: ({ params, abortSignal }) =>
    fetch(`/api/users/${params.id}`, { signal: abortSignal }).then(r => r.json()),
});

Note: returning undefined cleanly switches the resource to idle.

2) Search with debounce + cancellation

Signals are synchronous; debouncing is time-based.

A clean approach is: Signal input → RxJS debounce → back to Signal.

import { signal, resource } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';

const q = signal('');

const debouncedQ = toSignal(
  toObservable(q).pipe(
    map(x => x.trim()),
    debounceTime(250),
    distinctUntilChanged(),
  ),
  { initialValue: '' }
);

const results = resource({
  params: () => debouncedQ() ? ({ q: debouncedQ() }) : undefined,
  loader: async ({ params, abortSignal }) => {
    const r = await fetch(`/api/search?q=${encodeURIComponent(params.q)}`, { signal: abortSignal });
    if (!r.ok) throw new Error(`Search failed: ${r.status}`);
    return r.json() as Promise<{ items: any[] }>;
  },
});

3) Optimistic local state: local status

Resources support local updates:

  • .set(value)
  • .update(fn)

When you do that, status becomes local.

// optimistic update
user.set({ ...user.value(), displayName: nextName });

// background refresh
user.reload();

4) Error boundaries + fallbacks

A safe computed pattern:

import { computed } from '@angular/core';

const safeUserName = computed(() => {
  if (!user.hasValue()) return 'Unknown';
  return user.value().displayName ?? 'Unknown';
});

Rule: never assume value() exists unless guarded by hasValue() or status checks.

httpResource: HttpClient as signals

httpResource wraps HttpClient and returns:

  • request status as signals
  • response as signals
  • works through Angular HTTP stack (interceptors, auth, logging)

Use it when:

  • you rely on interceptors (auth tokens, retries, headers)
  • you want consistent HttpClient behavior (vs raw fetch)
  • you want request lifecycle without manual Subject/BehaviorSubject scaffolding

Exact import path and API shape can vary by Angular version because this area is evolving.

Resource vs RxJS vs rxResource

Use Signals when:

  • state is local/UI-facing
  • derived state matters (computed)
  • you want predictable “pull-based” reads

Use RxJS when:

  • events/time matter (debounce, merge, retry, websockets)
  • you need operators like switchMap, combineLatest, shareReplay

Use Resource when:

  • you need async data as signals
  • you want cancellation/status built-in
  • you want deterministic UI logic without custom loading/error flags

Use rxResource when:

  • your async source is naturally an RxJS stream
  • you want Resource lifecycle + RxJS composition
  • you’re already in the RxJS ecosystem and want the Resource ref type

Angular v20+ note: If you’re migrating rxResource, options renamed:
request → params, loader → stream. If you still pass request, TypeScript will error.

Testing Resources

Test:

  • status transitions (loading → resolved, loading → error, reloading)
  • cancellation when params change quickly
  • UI guards (hasValue() and status())

Pseudo-example:

import { signal, resource } from '@angular/core';

it('goes to resolved when loader completes', async () => {
  const id = signal('1');
  const user = resource({
    params: () => ({ id: id() }),
    loader: async () => ({ name: 'Ada' }),
  });

  expect(user.status()).toBe('loading');
  await Promise.resolve();
  expect(user.status()).toBe('resolved');
  expect(user.value().name).toBe('Ada');
});

SSR notes and PendingTasks

SSR needs to know when your app is “stable” to produce the final HTML.
Modern Angular trends toward explicit pending task tracking (vs ZoneJS stability heuristics).

Practical guidance:

  • keep async loads in Resources
  • ensure SSR runtime can await those pending operations
  • prefer Angular-provided primitives (PendingTasks) where available

Pitfalls & Anti‑Patterns

  • Async inside computed() → computed must be pure & synchronous
  • Ignoring AbortSignal → invites race conditions
  • Reading value() without guards → causes errors in loading/error/idle
  • Forcing websocket streams into Resource → Resources are request lifecycle, not infinite streams
  • Noisy params (whitespace, fast typing) → normalize + debounce before the resource

Upgrade & Safety Checklist

  • ✅ Treat Resource as experimental: isolate behind a service boundary.
  • ✅ Always use AbortSignal when supported.
  • ✅ Drive UI from status() instead of ad-hoc booleans.
  • ✅ Return undefined from params() for “no request”.
  • ✅ Use immutable updates (set({...}), set([...])), don’t mutate nested objects.
  • ✅ Use .reload() for retry/refresh (preserves value on reloading).
  • ✅ Measure: cancellation should eliminate stale response bugs.

Conclusion

Resources bring a missing piece to signal-first Angular:

  • async data with sync reads
  • built-in cancellation
  • status-driven UIs
  • fewer custom “loading/error flags” across components

If you paste your current resource({ ... }) block, I’ll rewrite it into a production-ready version with:

  • strong types
  • cancellation
  • guarded template access
  • ergonomic status UI

✍️ Cristian Sifuentes

Building scalable Angular + .NET systems, obsessed with clean reactivity, performance, and production ergonomics.

Is Lovable a Good Starting Point for Developers?

Should you use Lovable, Bolt, or Replit to start coding? How to choose based on your stack, your goals, and how “opinionated” you want your tools to be.

TL;DR

Lovable can be a great starting point if you’re okay with React + Tailwind + Supabase. If that stack feels alien, you might be fighting the tool. Check the stack first, then decide between a template (faster, but more variation in generated code) or a blank page (more control, more prompting). Lovable shines for MVPs, internal tools, and weekend projects—less so for heavy custom logic or when you need to debug complex flows.

Is it easier and faster to use Lovable, Bolt, or Replit to start coding?

It depends. The real question is: does the underlying stack fit you?

You need to know and analyse the stack used by each tool. Depending on your stack, it can make sense to choose one over the other.

Example: Lovable uses React and Tailwind CSS. If you don’t feel comfortable with these, that’s already a good reason not to start with Lovable. You’ll spend more time learning the stack than benefiting from the AI.

Are the technologies “opinionated” enough?

By “opinionated” I mean: is there a strong enough standardisation of the generated code that, whatever the app produces, you’re likely to feel at ease with it—so it’s easy to take over and keep coding afterwards?

Lovable’s output tends to follow patterns: React components, Supabase client usage, Tailwind classes. If you’re familiar with that style, you can read and extend the code. If not, the “AI magic” can quickly feel like a black box. So: stack fit + “school of thought” both matter.

Template or blank page?

Once you’ve chosen your platform (here, Lovable), you still have to decide: start from a template or from a blank page?

  • Templates are themselves AI-generated. The code can differ a lot from one template to another, depending on the initial prompt and the iterations. You get speed and a pre-shaped structure, but less predictability.

  • Blank page gives you more control over the first prompt and the architecture, but you have to describe more from scratch.

There’s no universal best choice: use a template when you want to “see something working” fast; use a blank page when you have a clear mental model of what you want to build.

What Lovable is good at

  • Full-stack from a single prompt. (I mean, if you are lucky enough) Describe a “task manager with auth and a Supabase database” and you get a working app: React frontend, Supabase backend, auth, and a deployed URL.

  • Visual editing. Click elements to change copy, spacing, and colours. Helpful when you don’t want to dive into the code.

  • Very low friction. Chat, write a prompt, or upload a Figma screenshot. Good for non-devs and devs who want to move fast.

  • GitHub sync. You can export and own the code. The stack is React, TypeScript, Supabase, Tailwind—familiar and portable.

Its limits

  • Complex logic. Multi-tenant auth, heavy business rules, or highly custom backends often need manual work or a different tool.

  • Credits. Usage is credit-based. Large or frequent changes can burn through them; you need to prompt efficiently.

  • Debugging. When something breaks, “Try to fix” and generic errors can send you in circles. Exporting to a local repo and debugging in an IDE is a common escape hatch.

Real-world use cases

Lovable shines for: idea validation, internal tools, simple SaaS, dashboards, marketing sites, and the “weekend project → live app” path. On Made with Lovable, you’ll see projects all built and shipped by small teams or solo builders (mostly vibe coded and the code is from Lovable – not modified in Claude code or Cursor).

Choose Lovable if: you want to go from idea to deployed app with auth and a database in a few hours, and you’re okay with the Lovable + Supabase model.

Wrapping up

Lovable is a good starting point for developers when:

  1. Your stack matches: React, Tailwind, Supabase feel comfortable (or you’re willing to learn them).

  2. You want an opinionated, standardised style so you can take over the generated code without too much friction.

  3. You choose wisely between template and blank page depending on whether you prioritise speed or control.

  4. Your goal is an MVP, a tool, or a fast prototype—not (at least at first) a highly custom or regulated system.

If that fits, Lovable can get you from zero to a real, deployed app very quickly. If not, tools like Bolt (more code visibility) or Replit (different stack and environment) might suit you better.

Tags for DEV: ai webdev vibecoding react beginners discuss

If you found this useful? Drop a comment below. Let me know your though

Architectural Drift: The Reason For JavaScript/Typescript Codebases To Break Down Over Time

TL;DR: Drifting occurs in every codebase. Codebases do not break down due to a specific problem; they are drifting due to small changes (such as importing for analytics tracking) that become significant over time from a maintenance standpoint. You will not discover these issues with ESLint or unit tests. The long-term result is that after 6 months, you will be scared to touch anything in your codebase. This article describes what is causing the drift in your codebase and what you can do about it.

I submitted a PR with 3 lines of code including a simple import statement that would enable other developers to track user activity.
I received notification that 47 tests failed.
Not because of an issue in the code, but because that structure caused a circular connection to all four packages and that meant that the test runner would not even be able to access the packages anymore.

Sounds similar to something you may have seen before?

Most licensed software written in JavaScript or TypeScript does not simply fall apart.

It collapses over time.

All the tests continue to pass, and there are no issues flagged by ESLint.

However, if you change the code with each production release; with each build as a result; you feel increasingly anxious.

All your programmers give you the impression that there are portions of the code where they’d prefer not to work.

“Don’t make any changes to the utilities file,” is a common phrase heard by a programmer.

Every programmer has been through similar examples of having no confidence in the changes they’re making.

Architectural Drift includes all the various effects of creating poor architecture; it does, however, include the continual decay of an already established piece of architecture. In this case, continual decay has created an increasing difficulty in supporting the ongoing enhancement of a given solution.

Some examples of what to look out for that indicate architectural drift has occurred:

  • You have at least one or more files that were initially under 50 lines long, but have grown by hundreds or thousands of lines.
  • Changing one property on one of your type definitions has caused you to modify 15 or more other files.
  • You are importing @app/shared from nearly every source file in your application.
  • There is a lack of understanding in the team about why user interface code is imported in the backend.
  • Several random tests fail with a message such as, “Cannot reference X until it has been initialized.”
  • There exist frequent merge conflicts on the same file, even with two different developers working in completely separate areas of the application.

The anatomy of drift: real examples

Once you can recognize that at least two of the above issues apply, it’s highly likely you’re experiencing architectural drift. Examples of architectural drift in real-world settings follow.

Example 1: The “Friday afternoon import”

It’s 5:47 PM. You need to ship a feature. The orders module needs to call something from payments.

// orders/createOrder.ts
import { validatePaymentMethod } from '../payments/validation';

export async function createOrder(items: Item[], paymentMethod: string) {
  if (!validatePaymentMethod(paymentMethod)) {
    throw new Error('Invalid payment method');
  }
  // ... rest of the logic
}

Seems fine, right? But payments already imports from orders:

// payments/processPayment.ts
import { getOrderTotal } from '../orders/utils';

export async function processPayment(orderId: string) {
  const total = await getOrderTotal(orderId);
  // ...
}

Well done! You have now created a circular dependency.

Bundling tools are smart. Your code may still work, but you now have two modules that cannot be separated. There is no way to independently test them. You cannot create a separate package for one of the modules. Eventually you will find that when you attempt to import one of the modules, the order in which you imported them matters, and you will receive confusingly ‘undefined’ JavaScript runtime errors.

Example 2: The Growing God Module

Every project has that one magical file that people turn to first when they begin coding. The magic usually starts off innocently enough:|

// shared/utils.ts
export function formatDate(date: Date): string { /* ... */ }
export function formatCurrency(amount: number): string { /* ... */ }

Six months later:

// shared/utils.ts - now 2847 lines
export function formatDate(date: Date): string { /* ... */ }
export function formatCurrency(amount: number): string { /* ... */ }
export function validateEmail(email: string): boolean { /* ... */ }
export function parseJWT(token: string): JWTPayload { /* ... */ }
export function calculateTax(amount: number, region: string): number { /* ... */ }
export function sendAnalytics(event: AnalyticsEvent): void { /* ... */ }
export function debounce<T extends Function>(fn: T, ms: number): T { /* ... */ }
export function deepClone<T>(obj: T): T { /* ... */ }
export function encryptPassword(password: string): string { /* ... */ }
// ... 200 more exports

The file currently has the following characteristics:

  • 47 modules depend on it (fan-in).
  • 23 of its own dependencies (fan-out).
  • 89 commits during the previous quarter (churn).

It is no longer a utility module, and it has become a god module, a single point of failure, thereby making your entire codebase fragile.

Example 3: Layer Violation Cascade

You are creating a clean architecture, where the domain logic is pure and clean, while infrastructure is responsible for all of the dirty tasks.

However, someone proceeded to add analytics:

// domain/userService.ts
import { trackEvent } from '@app/ui/analytics'; // 💀

export function registerUser(data: UserData) {
  const user = createUser(data);
  trackEvent('user_registered', { userId: user.id });
  return user;
}

Why does this appear to be complicated? It’s only one extra import!

However, your Application Domain now relies on the UI.

The Infrastructure Layer, which relies on the Application Domain, now also has to depend on the UI, through the Application Domain.

The Application Domain that was previously thought of as “pure”, now has to have the entire React Dependency Tree existing below it, in order for it to function.

You cannot extract your Application Domain Logic from your Application Domain Layer, for use in a Common Library.

You will have to create test mocks for the UI Analytics in order to test your Application Domain Logic.

Just one additional import, but it has a cascading effect.

Example 4: Shotgun Surgery

Adding the “Middle Name” field for users was an incredibly painless modification.

Files modified: 23
Lines added: 147
Lines deleted: 12

You had to update:

  • Domain

    • User type definition
  • Transport / API

    • UserDTO
    • CreateUserRequest
    • UpdateUserRequest
    • UserResponse
  • Infrastructure

    • Database migration
    • 2 seed scripts
  • Application Layer

    • 3 API endpoints
    • 5 validation functions
  • UI

    • 2 form components
  • Tests

    • 4 test files

This is called shotgun surgery.

A single logical change resulted in modifications scattered across the entire codebase.

The entities are so tightly coupled that it becomes impossible to change one without touching many others.

The architectural drift caused by JavaScript and TypeScript projects is exacerbated by the ease with which they can be developed.

Easy Imports

You don’t need to think about it; you don’t have to worry about compiler warnings. Just type import { something } from 'wherever' and you’re good to go.

Boundaries Are Only Suggestions

There are no rules governing module boundaries as there are with Java packages and CI assemblies; all of it is based on conventions, which are easily ignored.

Monorepo Growth is Phenomenal

What started out as 3 packages has grown to 27 – the original maintainers have all left, and nobody remembers why the @app/legacy-utils package exists.

Loss of Context

The note you added to the code that says, “TEMPORARY – remove after Q3 migration” is still there – written in 2021. No context remains for you to remember what you were doing at the time.

ESLint is a Great Tool

ESLint has a lot of value, but its value is only for its intended purpose.

  • Code Quality / Style Consistency / Common Bugs all are represented in ESLint.

ESLint operates on a file-level basis and in real-time.

Even no-restricted-imports falls short because of its limitations:

  • You need to know ahead of time what you need to restrict
  • It doesn’t adapt to the ever-changing architecture.
  • And it can’t tell you, “Is this PR going to ruin the architecture or improve it?”

Architectural drift is temporal. ESLint is not.

Code reviews may be used to catch architectural drift. However, as code review is an effective way of validating the cleanliness and quality of code, they aren’t as effective when it comes to testing architectural drift since:

  • The number of reviewers will frequently change and thus will have no historical context of how the code was constructed.
  • The “I’ll fix this later” mentality becomes commonplace and nothing gets fixed.
  • No one will remember how the architecture was intended to look, thus all the previous diagrams or other documentation that supported the intended architecture become useless.

Architectural drift occurs while maintaining that code is supposed to function correctly. Thus, unless there is some sort of automated tool to assist with keeping architectural drift in check, this is an outcome that is relatively easy to miss when you are trying to determine what the original architecture was expected to resemble.

The real problem: architecture has no baseline

The central problem is:

We do not consider Architecture to be a Versioned Artifact

Files are tracked in Git, and CI checks their current state.

What about Architecture? It can reside in lots of places:

  • Architecture Decision Records that no one ever reads
  • Diagrams that are 3 versions out of date
  • The minds of departed developers

Because of this, teams have no way of reliably answering this question:

Will this PR introduce an architectural regression?

With no baseline, every change is made in a vacuum.

A different approach: regression-based architecture

Instead of attempting to pursue an example of ‘perfect’ architecture you could do:

  • Don’t build it out
  • Don’t refactor it
  • Let the current modules stay the same, then allow for no new architectural regression

This process is composed of the following four steps:

  1. Determine your current design and module structure.
  2. Document that architecture/design and module structure as the initial baseline.
  3. Each Pull Request (PR) will be compared against the documented initial baseline
  4. The Continuous Integration (CI) process will only allow you to fail CI would be if a new architectural violation occurs.

This process is similar to how you think of:

  • Regression Testing
  • Snapshot Testing
  • Performance Budgets

This is not an assertion that the architecture is the best, this is an assertion that the architecture has not regressed.

What this looks like in practice

Consider a typical monorepo:

packages/
  ui/         → depends on domain
  domain/     → depends on shared
  infra/      → depends on domain
  shared/     → depends on nothing

Everything is reasonably layered. You capture this as a baseline.

Now someone opens a PR:

// domain/userService.ts
import { trackEvent } from '@repo/ui/analytics';

With regression-based checking, CI responds:

❌ Architectural regression detected

New violation: domain → ui
  domain/userService.ts imports @repo/ui/analytics

This dependency did not exist in the baseline.
This would create a layer violation (domain importing from ui).

Importantly, there is no room for differing viewpoints or discussions regarding this as this is purely informational: “Prior to this, it was nonexistent. Here are some of the ramifications of having such an object in existence.”

As a consequence, the developer will have two choices:

  • Address the problem (Move the analytic data to shared repository).
  • Intentionally adjust the baseline following group coordination.

Regardless of the angle chosen, it was a thoughtful determination/decision, rather than a random occurrence.

Tools that can help

There are several tools available for anyone interested in implementing a similar approach, including:

Archlint is an option specifically for Javascript and Typescript Projects.

  • Analyses how modules depend on each other
  • Creates and maintains an Architectural Baseline
  • Compares PRs (Pull Requests) to the defined Architectural Baseline
  • Identifies Code Smells including Circular Dependencies, God Objects, Layer Violation etc.

It does not help you redesign your system, but rather, makes architectural drift apparent very early so that you can fix it while it is still inexpensive to do so.

Key takeaways

The concept referred to as “Architectural Drift” happens gradually and cannot be attributed to a specific PR. It is impossible to track Architectural Drift using the existing tools in the software development ecosystem (ESLint, automated tests, manual code review). User-defined static architecture rules do not adequately address this issue because, unlike Architectural Drift, they do not evolve over time. Instead of focusing on the “ideal” architecture, tracking the presence/absence of regressions is the best approach.

We should think of Architectural Drift similarly to how we think of behavior; that is, the behavior of our architecture can also drift back over time, which causes regression and can negatively impact our applications, therefore we must also monitor our architectural drift and track the number of times our architecture verges on a regression — while providing a mechanism for us to track any regressions.

While it may not be essential to have the perfect architecture, we should have sufficient confidence that our architecture isn’t becoming worse without our knowledge.

What’s the worst architectural drift you’ve seen in a codebase? Drop a comment below — I’d love to hear your horror stories. 👇

Building an API in Go to Manage Files on Amazon S3

When I decided to create my blog, I chose Amazon S3 to host the images for my posts. The decision was practical (S3 is reliable, inexpensive, and scales well), but I also saw it as an opportunity: using a real, everyday problem to deepen my fluency in the AWS ecosystem, especially around security, automation, and integrations.

After creating a Go program that allows me to optimize and convert images to WebP, which I detailed in this article, I decided to build an API—also in Go—that allows me to manage my AWS account. With it, I can perform single or multiple file uploads, manage buckets, perform intelligent listings, and handle efficient streaming downloads.

That’s how this API came to life: a Go application that allows me to manage files and buckets in S3 with a productivity-focused workflow, supporting single and multiple uploads, paginated listings, streaming downloads, and the generation of temporary URLs (presigned URLs) for secure access.

To ensure agility in day-to-day usage, the application is fully containerized with Docker. This way, I spin up the container and use the endpoints (which I keep saved in Apidog). With each upload, I instantly receive the final file link, ready to paste into a blog post. This is exactly the “fast and easy” workflow I was aiming for.

Upload via Apidog

What This API Does

In short, this API covers:

  • Single and multiple uploads (with concurrency).
  • Paginated listing of objects with useful metadata.
  • Streaming downloads, avoiding loading entire files into the API’s memory.
  • Presigned URLs for temporary and secure access to private buckets.
  • Bucket management (create, list, view statistics, empty, and delete).

Architecture and Project Structure

I structured the project following the recommendations of the Go Standards Project Layout. This organization, inspired by Clean Architecture, allows the application to start robustly while making it easier to evolve features and maintain the system over time.

How Each Part Connects

A simple way to understand the architecture is to imagine a flow:

  • Handler (HTTP): translates the protocol, performs minimal validation, and returns the response.
  • Service: applies business rules, validations, security, concurrency, and timeouts.
  • Repository (interface): defines the storage contract.
  • S3 Repository (implementation): handles integration details with the AWS SDK.

Directory Structure

s3-api/
├── .github/
│   └── workflows/            # CI: pipelines (tests, lint, build) with GitHub Actions
├── cmd/
│   └── api/
│       └── main.go           # Bootstrap: loads config, initializes dependencies, starts the HTTP server
├── internal/
│   ├── config/               # Centralized configuration: typed env vars + defaults
│   ├── middleware/           # HTTP middlewares: logging, timeout, recovery, etc.
│   └── upload/               # Domain module (files and buckets in S3)
│       ├── entity.go         # Domain entities and response DTOs
│       ├── errors.go         # Domain errors (no HTTP coupling)
│       ├── repository.go     # Storage contract (interface)
│       ├── s3_repository.go  # Contract implementation using AWS SDK v2
│       ├── service.go        # Business rules: validations, concurrency, timeouts
│       ├── handler.go        # HTTP handlers: translate request/response and call the Service
│       └── service_test.go   # Unit tests for the Service (with repository mocks)
├── .env                      # Local config (NEVER commit; use .gitignore)
├── Dockerfile                # Multi-stage build (slim binary)
└── docker-compose.yml        # Local environment: runs API with env vars and ports

Important Decisions in This Structure

  • cmd/api/main.go: This is where Dependency Injection is applied. The main function creates the AWS client, the repository, and the service, wiring them together. If tomorrow we decide to replace S3 with a local database, we only need to change a single line here.
  • internal/middleware/: This is where we implement structured logging middleware (using slog) and a timeout middleware, ensuring that no request hangs indefinitely and protecting the overall health of the application.
  • internal/upload/service.go: This is where we use advanced concurrency with errgroup. When performing multiple uploads, the service launches several goroutines to communicate with S3 in parallel, drastically reducing response time.
  • internal/upload/errors.go: Instead of returning generic strings, we use typed errors. This allows the Handler to elegantly determine whether it should return a 400 Bad Request or a 404 Not Found.

Endpoints

The API is divided into two main groups: File management and Bucket management.

Files

Method Endpoint Description
POST /api/v1/upload Upload a single file
POST /api/v1/upload-multiple Multiple upload
GET /api/v1/list Paginated listing
GET /api/v1/download Streaming download
GET /api/v1/presign Generate temporary (presigned) URL
DELETE /api/v1/delete Remove an object from the bucket

Buckets

Method Endpoint Description
GET /api/v1/buckets/list List account buckets
POST /api/v1/buckets/create Create bucket
GET /api/v1/buckets/stats Statistics
DELETE /api/v1/buckets/delete Delete bucket
DELETE /api/v1/buckets/empty Empty bucket

Key Highlights and Technical Decisions

Decisions that have the greatest impact on real-world API usage.

UUID v7 for file names

For uploads, I use UUID v7 to rename files. In addition to avoiding collisions and preventing exposure of original names, v7 preserves temporal ordering. This helps with organization in S3, facilitates auditing, and leaves the door open for future evolution (for example, database indexing without losing order).

Streaming to avoid memory spikes

For downloads, the goal is to keep RAM usage stable. Instead of “downloading everything and then returning it,” I treat it as a stream: data flows from S3 directly to the client without becoming a large in-memory buffer inside the API.

Real file type (MIME) validation

File extensions are easy to spoof. For this reason, instead of trusting .jpg/.png, I validate the file content (header) to identify the actual type. This reduces the risk of uploading malicious content disguised as images.

Timeouts as resource protection

Since the API depends on external services (S3), I do not want requests to hang indefinitely. Timeouts prevent excessive consumption of goroutines and connections when the network is unstable or the provider is slow.

Initializing the Project

The first step is to prepare the development environment. Go uses the module system (go mod) to manage dependencies, ensuring that the project is reproducible on any machine.

# Create the root folder and enter the directory
mkdir s3-api && cd s3-api

# Initialize the module (replace with your repository if needed)
go mod init github.com/JoaoOliveira889/s3-api

# Create the directory tree following the Go Standard Project Layout
mkdir -p cmd/api internal/upload internal/config internal/middleware

Dependency Management

I use the AWS SDK for Go v2 and a few libraries to make the project more robust (HTTP routing, env loading, UUIDs, testing). The goal is to keep the dependency set lean and well-justified, avoiding anything unnecessary.

# SDK core and configuration/credentials management
go get github.com/aws/aws-sdk-go-v2
go get github.com/aws/aws-sdk-go-v2/config

# S3 service client
go get github.com/aws/aws-sdk-go-v2/service/s3

# Gin Gonic: high-performance HTTP framework
go get github.com/gin-gonic/gin

# GoDotEnv: load environment variables from a .env file
go get github.com/joho/godotenv

# UUID: generate unique identifiers (v7)
go get github.com/google/uuid

# Testify: assertions and mocks for tests
go get github.com/stretchr/testify

Domain Layer

This is where the entities and contracts live. The core idea is: the domain describes what the system does, without being coupled to S3 itself.

Entities

Entities represent the main data structures of the application: files, listing metadata, bucket statistics, and pagination.

package upload

import (
    "io"
    "time"
)

type File struct {
    Name        string            `json:"name"`         // final object name in storage
    URL         string            `json:"url"`          // resulting URL after upload
    Content     io.ReadSeekCloser `json:"-"`            // Content is not serialized to JSON because it represents the file stream
    Size        int64             `json:"size"`
    ContentType string            `json:"content_type"`
}

type FileSummary struct {
    Key               string    `json:"key"`            // full object key in S3
    URL               string    `json:"url"`
    Size              int64     `json:"size_bytes"`
    HumanReadableSize string    `json:"size_formatted"`
    Extension         string    `json:"extension"`
    StorageClass      string    `json:"storage_class"`
    LastModified      time.Time `json:"last_modified"`
}

type BucketStats struct {
    BucketName         string `json:"bucket_name"`
    TotalFiles         int    `json:"total_files"`
    TotalSizeBytes     int64  `json:"total_size_bytes"`
    TotalSizeFormatted string `json:"total_size_formatted"`
}

type BucketSummary struct {
    Name         string    `json:"name"`
    CreationDate time.Time `json:"creation_date"`
}

type PaginatedFiles struct {
    Files     []FileSummary `json:"files"`
    NextToken string        `json:"next_token,omitempty"` // continuation token (pagination token)
}

Repository Interface

The repository defines a storage contract. This way, the Service layer does not “know” it is S3. It only knows that there is an implementation capable of storing, listing, downloading, and deleting.

Note: by using streaming for uploads/downloads, the API avoids loading entire files into memory, keeping RAM usage much more predictable.

package upload

import (
    "context"
    "io"
    "time"
)

// Repository defines the storage contract.
type Repository interface {
    // Upload stores the file in the bucket and returns the final object URL.
    Upload(ctx context.Context, bucket string, file *File) (string, error)

    // GetPresignURL generates a temporary URL for secure downloads.
    GetPresignURL(ctx context.Context, bucket, key string, expiration time.Duration) (string, error)

    // Download returns a stream (io.ReadCloser) to enable streaming without loading everything into memory.
    Download(ctx context.Context, bucket, key string) (io.ReadCloser, error)

    // List returns a page of files using a continuation token.
    List(ctx context.Context, bucket, prefix, token string, limit int32) (*PaginatedFiles, error)

    // Delete removes a specific object from the bucket.
    Delete(ctx context.Context, bucket string, key string) error

    // CheckBucketExists verifies whether the bucket exists and is accessible.
    CheckBucketExists(ctx context.Context, bucket string) (bool, error)

    // CreateBucket creates a bucket (usually with prior validations in the Service layer).
    CreateBucket(ctx context.Context, bucket string) error

    // ListBuckets lists all buckets available in the account.
    ListBuckets(ctx context.Context) ([]BucketSummary, error)

    // GetStats aggregates bucket statistics.
    GetStats(ctx context.Context, bucket string) (*BucketStats, error)

    // DeleteAll removes all objects from the bucket.
    DeleteAll(ctx context.Context, bucket string) error

    // DeleteBucket deletes the bucket.
    DeleteBucket(ctx context.Context, bucket string) error
}

Infrastructure Layer

With the contract defined, I can create the concrete implementation that talks to AWS. The goal here is to translate the domain’s needs into calls to the AWS SDK v2.

package upload

import (
    "context"
    "fmt"
    "io"
    "path/filepath"
    "strings"
    "time"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "github.com/aws/aws-sdk-go-v2/service/s3/types"
)

type S3Repository struct {
    client *s3.Client
    region string
}

func NewS3Repository(client *s3.Client, region string) Repository {
    return &S3Repository{
        client: client,
        region: region,
    }
}

func (r *S3Repository) Upload(ctx context.Context, bucket string, file *File) (string, error) {
    input := &s3.PutObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(file.Name),
        Body:   file.Content, // stream: avoids loading the whole file into memory
    }

    _, err := r.client.PutObject(ctx, input)
    if err != nil {
        return "", fmt.Errorf("failed to upload: %w", err)
    }

    // Direct URL
    return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, r.region, file.Name), nil
}

func (r *S3Repository) List(ctx context.Context, bucket, prefix, token string, limit int32) (*PaginatedFiles, error) {
    input := &s3.ListObjectsV2Input{
        Bucket:            aws.String(bucket),
        Prefix:            aws.String(prefix),
        ContinuationToken: aws.String(token),
        MaxKeys:           aws.Int32(limit),
    }

    // In the SDK, an empty token must be nil to avoid sending an invalid token.
    if token == "" {
        input.ContinuationToken = nil
    }

    output, err := r.client.ListObjectsV2(ctx, input)
    if err != nil {
        return nil, fmt.Errorf("failed to list objects: %w", err)
    }

    var files []FileSummary
    for _, obj := range output.Contents {
        key := aws.ToString(obj.Key)
        size := aws.ToInt64(obj.Size)
        files = append(files, FileSummary{
            Key:               key,
            Size:              size,
            HumanReadableSize: formatBytes(size),
            StorageClass:      string(obj.StorageClass),
            LastModified:      aws.ToTime(obj.LastModified),
            Extension:         strings.ToLower(filepath.Ext(key)),
            URL:               fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, r.region, key),
        })
    }

    next := ""
    if output.NextContinuationToken != nil {
        next = *output.NextContinuationToken
    }

    return &PaginatedFiles{Files: files, NextToken: next}, nil
}

func (r *S3Repository) Delete(ctx context.Context, bucket, key string) error {
    _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    return err
}

func (r *S3Repository) Download(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
    output, err := r.client.GetObject(ctx, &s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    if err != nil {
        return nil, err
    }

    // Return Body as a stream so the handler can io.Copy directly to the response.
    return output.Body, nil
}

func (r *S3Repository) GetPresignURL(ctx context.Context, bucket, key string, exp time.Duration) (string, error) {
    // The presign client generates temporary URLs without making the object public.
    pc := s3.NewPresignClient(r.client)
    req, err := pc.PresignGetObject(ctx, &s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    }, s3.WithPresignExpires(exp))
    if err != nil {
        return "", err
    }
    return req.URL, nil
}

func (r *S3Repository) CheckBucketExists(ctx context.Context, bucket string) (bool, error) {
    _, err := r.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
    if err != nil {
        return false, nil
    }
    return true, nil
}

func (r *S3Repository) CreateBucket(ctx context.Context, bucket string) error {
    _, err := r.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)})
    return err
}

func (r *S3Repository) ListBuckets(ctx context.Context) ([]BucketSummary, error) {
    out, err := r.client.ListBuckets(ctx, &s3.ListBucketsInput{})
    if err != nil {
        return nil, err
    }
    var res []BucketSummary
    for _, b := range out.Buckets {
        res = append(res, BucketSummary{
            Name:         aws.ToString(b.Name),
            CreationDate: aws.ToTime(b.CreationDate),
        })
    }
    return res, nil
}

func (r *S3Repository) DeleteBucket(ctx context.Context, bucket string) error {
    _, err := r.client.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(bucket)})
    return err
}

func (r *S3Repository) DeleteAll(ctx context.Context, bucket string) error {
    out, err := r.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
    if err != nil || len(out.Contents) == 0 {
        return err
    }

    var objects []types.ObjectIdentifier
    for _, obj := range out.Contents {
        objects = append(objects, types.ObjectIdentifier{Key: obj.Key})
    }
    _, err = r.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
        Bucket: aws.String(bucket),
        Delete: &types.Delete{Objects: objects},
    })
    return err
}

func (r *S3Repository) GetStats(ctx context.Context, bucket string) (*BucketStats, error) {
    out, err := r.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
    if err != nil {
        return nil, err
    }
    var totalSize int64
    for _, obj := range out.Contents {
        totalSize += aws.ToInt64(obj.Size)
    }
    return &BucketStats{
        BucketName:         bucket,
        TotalFiles:         int(len(out.Contents)),
        TotalSizeBytes:     totalSize,
        TotalSizeFormatted: formatBytes(totalSize),
    }, nil
}

func formatBytes(b int64) string {
    const unit = 1024
    if b < unit {
        return fmt.Sprintf("%d B", b)
    }
    div, exp := int64(unit), 0
    for n := b / unit; n >= unit; n /= unit {
        div *= unit
        exp++
    }
    return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}

Technical Highlights in the Infrastructure Layer

  • Context (context.Context): network operations respect cancellation and timeouts; if the request is dropped, unnecessary work and resource consumption are avoided.
  • Efficient pagination: essential for large buckets; clients can navigate results without overloading the API.
  • Presigned URLs: temporary and secure access to private buckets without exposing them publicly.
  • Data presentation: formatting sizes and metadata significantly improves the experience for API consumers.

Service Layer

This layer is responsible for orchestration: validations, business rules, timeouts, and concurrency.

One detail I particularly like here: the Service interface exposes what the application does, while the concrete implementation remains private, enforcing the use of a constructor (factory). This helps keep the design consistent and controlled.

package upload

import (
    "context"
    "fmt"
    "io"
    "log/slog"
    "net/http"
    "path/filepath"
    "regexp"
    "strings"
    "time"

    "github.com/google/uuid"
    "golang.org/x/sync/errgroup"
)

type Service interface {
    UploadFile(ctx context.Context, bucket string, file *File) (string, error)
    UploadMultipleFiles(ctx context.Context, bucket string, files []*File) ([]string, error)
    GetDownloadURL(ctx context.Context, bucket, key string) (string, error)
    DownloadFile(ctx context.Context, bucket, key string) (io.ReadCloser, error)
    ListFiles(ctx context.Context, bucket, ext, token string, limit int) (*PaginatedFiles, error)
    DeleteFile(ctx context.Context, bucket string, key string) error
    GetBucketStats(ctx context.Context, bucket string) (*BucketStats, error)
    CreateBucket(ctx context.Context, bucket string) error
    ListAllBuckets(ctx context.Context) ([]BucketSummary, error)
    DeleteBucket(ctx context.Context, bucket string) error
    EmptyBucket(ctx context.Context, bucket string) error
}

const (
    uploadTimeout       = 60 * time.Second
    deleteTimeout       = 5 * time.Second
    maxBucketNameLength = 63
    minBucketNameLength = 3
)

var (
    // S3 bucket names follow DNS rules (min/max length and charset). The regex covers the general pattern.
    bucketDNSNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)

    // Allowlist: validates the actual file type (detected MIME) instead of trusting the extension.
    allowedTypes = map[string]bool{
        "image/jpeg":      true,
        "image/png":       true,
        "image/webp":      true,
        "application/pdf": true,
    }
)

type uploadService struct {
    repo Repository
}

func NewService(repo Repository) Service {
    return &uploadService{repo: repo}
}

func (s *uploadService) UploadFile(ctx context.Context, bucket string, file *File) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, uploadTimeout) // prevents "hanging" requests in S3 calls
    defer cancel()

    if err := s.validateBucketName(bucket); err != nil {
        return "", err
    }

    if err := s.validateFile(file); err != nil {
        slog.Error("security validation failed", "error", err, "filename", file.Name)
        return "", err
    }

    // UUID v7 preserves temporal ordering and avoids collisions/exposure of original names.
    id, err := uuid.NewV7()
    if err != nil {
        slog.Error("uuid generation failed", "error", err)
        return "", fmt.Errorf("failed to generate unique id: %w", err)
    }

    file.Name = id.String() + filepath.Ext(file.Name)

    url, err := s.repo.Upload(ctx, bucket, file)
    if err != nil {
        slog.Error("repository upload failed", "error", err, "bucket", bucket)
        return "", err
    }

    file.URL = url
    slog.Info("file uploaded successfully", "url", url)
    return url, nil
}

func (s *uploadService) UploadMultipleFiles(ctx context.Context, bucket string, files []*File) ([]string, error) {
    // errgroup cancels the context if any goroutine fails.
    g, ctx := errgroup.WithContext(ctx)
    results := make([]string, len(files))

    for i, f := range files {
        i, f := i, f // avoids incorrect capture of loop variables
        g.Go(func() error {
            url, err := s.UploadFile(ctx, bucket, f)
            if err != nil {
                return err
            }
            results[i] = url
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }

    return results, nil
}

func (s *uploadService) GetDownloadURL(ctx context.Context, bucket, key string) (string, error) {
    if err := s.validateBucketName(bucket); err != nil {
        return "", err
    }

    // Presign is the recommended way to provide temporary access to private buckets.
    return s.repo.GetPresignURL(ctx, bucket, key, 15*time.Minute)
}

func (s *uploadService) DownloadFile(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
    if err := s.validateBucketName(bucket); err != nil {
        return nil, err
    }
    return s.repo.Download(ctx, bucket, key)
}

func (s *uploadService) ListFiles(ctx context.Context, bucket, ext, token string, limit int) (*PaginatedFiles, error) {
    if err := s.validateBucketName(bucket); err != nil {
        return nil, err
    }

    if limit <= 0 {
        limit = 10
    }

    res, err := s.repo.List(ctx, bucket, "", token, int32(limit))
    if err != nil {
        return nil, err
    }

    if ext == "" {
        return res, nil
    }

    var filtered []FileSummary
    target := strings.ToLower(ext)

    if !strings.HasPrefix(target, ".") {
        target = "." + target
    }

    for _, f := range res.Files {
        if strings.ToLower(f.Extension) == target {
            filtered = append(filtered, f)
        }
    }

    res.Files = filtered
    return res, nil
}

func (s *uploadService) DeleteFile(ctx context.Context, bucket string, key string) error {
    ctx, cancel := context.WithTimeout(ctx, deleteTimeout)
    defer cancel()

    if key == "" {
        return fmt.Errorf("file key is required")
    }

    if err := s.validateBucketName(bucket); err != nil {
        return err
    }

    return s.repo.Delete(ctx, bucket, key)
}

func (s *uploadService) GetBucketStats(ctx context.Context, bucket string) (*BucketStats, error) {
    if err := s.validateBucketName(bucket); err != nil {
        return nil, err
    }
    return s.repo.GetStats(ctx, bucket)
}

func (s *uploadService) CreateBucket(ctx context.Context, bucket string) error {
    if err := s.validateBucketName(bucket); err != nil {
        return err
    }

    exists, err := s.repo.CheckBucketExists(ctx, bucket)
    if err != nil {
        return err
    }
    if exists {
        return ErrBucketAlreadyExists
    }

    return s.repo.CreateBucket(ctx, bucket)
}

func (s *uploadService) DeleteBucket(ctx context.Context, bucket string) error {
    if err := s.validateBucketName(bucket); err != nil {
        return err
    }
    return s.repo.DeleteBucket(ctx, bucket)
}

func (s *uploadService) EmptyBucket(ctx context.Context, bucket string) error {
    if err := s.validateBucketName(bucket); err != nil {
        return err
    }
    return s.repo.DeleteAll(ctx, bucket)
}

func (s *uploadService) ListAllBuckets(ctx context.Context) ([]BucketSummary, error) {
    return s.repo.ListBuckets(ctx)
}

func (s *uploadService) validateBucketName(bucket string) error {
    bucket = strings.TrimSpace(strings.ToLower(bucket))
    if bucket == "" {
        return ErrBucketNameRequired
    }

    if len(bucket) < minBucketNameLength || len(bucket) > maxBucketNameLength {
        return fmt.Errorf("bucket name length must be between %d and %d", minBucketNameLength, maxBucketNameLength)
    }

    if !bucketDNSNameRegex.MatchString(bucket) {
        return fmt.Errorf("invalid bucket name pattern")
    }

    if strings.Contains(bucket, "..") {
        return fmt.Errorf("bucket name cannot contain consecutive dots")
    }

    return nil
}

func (s *uploadService) validateFile(f *File) error {
    seeker, ok := f.Content.(io.Seeker)
    if !ok {
        return fmt.Errorf("file content must support seeking")
    }

    buffer := make([]byte, 512)
    n, err := f.Content.Read(buffer)
    if err != nil && err != io.EOF {
        return fmt.Errorf("failed to read file header: %w", err)
    }

    // Reset the stream back to the beginning before upload.
    if _, err := seeker.Seek(0, io.SeekStart); err != nil {
        return fmt.Errorf("failed to reset file pointer: %w", err)
    }

    detectedType := http.DetectContentType(buffer[:n])
    if !allowedTypes[detectedType] {
        slog.Warn("rejected file type", "type", detectedType)
        return ErrInvalidFileType
    }

    return nil
}

Technical Highlights in the Service Layer

Concurrency with errgroup

In multiple uploads, I don’t upload files one by one. I trigger uploads in parallel and let errgroup manage cancellation: if one upload fails, the others are signaled to stop. In practice, the total time tends to be close to the slowest upload, rather than the sum of all uploads.

Security: file type validation

The validation reads a portion of the content to identify the actual file type, reducing the risk of malicious files with a “spoofed” extension.

Resilience: per-operation timeouts

Operations such as upload and delete have different characteristics; therefore, it makes sense to define distinct time limits to keep the API responsive and avoid stuck resources.

Encapsulation and dependency injection

The Service depends on a Repository. This makes the code highly testable: in tests, I can mock the repository without needing AWS.

Handler Layer

The Handler translates HTTP into the application domain: it extracts parameters, performs minimal validation, calls the Service, and returns JSON (or streaming in the case of downloads).

I chose Gin for its combination of performance, simplicity, and a rich middleware ecosystem.

package upload

import (
    "errors"
    "io"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
)

type Handler struct {
    service Service
}

func NewHandler(s Service) *Handler {
    return &Handler{service: s}
}

func (h *Handler) UploadFile(c *gin.Context) {
    bucket := c.PostForm("bucket")
    fileHeader, err := c.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "file field is required"})
        return
    }

    openedFile, err := fileHeader.Open()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
        return
    }
    defer openedFile.Close()

    file := &File{
        Name:        fileHeader.Filename,
        Content:     openedFile,
        Size:        fileHeader.Size,
        ContentType: fileHeader.Header.Get("Content-Type"),
    }

    url, err := h.service.UploadFile(c.Request.Context(), bucket, file)
    if err != nil {
        h.handleError(c, err)
        return
    }

    c.JSON(http.StatusCreated, gin.H{"url": url})
}

func (h *Handler) UploadMultiple(c *gin.Context) {
    bucket := c.PostForm("bucket")
    form, err := c.MultipartForm()
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid multipart form"})
        return
    }

    filesHeaders := form.File["files"]
    if len(filesHeaders) == 0 {
        c.JSON(http.StatusBadRequest, gin.H{"error": "no files provided"})
        return
    }

    var filesToUpload []*File
    for _, header := range filesHeaders {
        openedFile, err := header.Open()
        if err != nil {
            continue
        }

        filesToUpload = append(filesToUpload, &File{
            Name:        header.Filename,
            Content:     openedFile,
            Size:        header.Size,
            ContentType: header.Header.Get("Content-Type"),
        })
    }

    defer func() {
        for _, f := range filesToUpload {
            f.Content.Close()
        }
    }()

    urls, err := h.service.UploadMultipleFiles(c.Request.Context(), bucket, filesToUpload)
    if err != nil {
        h.handleError(c, err)
        return
    }

    c.JSON(http.StatusCreated, gin.H{"urls": urls})
}

func (h *Handler) GetPresignedURL(c *gin.Context) {
    bucket := c.Query("bucket")
    key := c.Query("key")

    url, err := h.service.GetDownloadURL(c.Request.Context(), bucket, key)
    if err != nil {
        h.handleError(c, err)
        return
    }

    c.JSON(http.StatusOK, gin.H{"presigned_url": url})
}

func (h *Handler) DownloadFile(c *gin.Context) {
    bucket := c.Query("bucket")
    key := c.Query("key")

    stream, err := h.service.DownloadFile(c.Request.Context(), bucket, key)
    if err != nil {
        h.handleError(c, err)
        return
    }
    defer stream.Close()

    c.Header("Content-Disposition", "attachment; filename="+key)
    c.Header("Content-Type", "application/octet-stream")

    _, _ = io.Copy(c.Writer, stream)
}

func (h *Handler) ListFiles(c *gin.Context) {
    limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))

    result, err := h.service.ListFiles(
        c.Request.Context(),
        c.Query("bucket"),
        c.Query("extension"),
        c.Query("token"),
        limit,
    )

    if err != nil {
        h.handleError(c, err)
        return
    }

    c.JSON(http.StatusOK, result)
}

func (h *Handler) DeleteFile(c *gin.Context) {
    err := h.service.DeleteFile(c.Request.Context(), c.Query("bucket"), c.Query("key"))
    if err != nil {
        h.handleError(c, err)
        return
    }

    c.Status(http.StatusNoContent)
}

func (h *Handler) GetBucketStats(c *gin.Context) {
    bucket := c.Query("bucket")
    if bucket == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "bucket parameter is required"})
        return
    }

    stats, err := h.service.GetBucketStats(c.Request.Context(), bucket)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, stats)
}

func (h *Handler) CreateBucket(c *gin.Context) {
    var body struct {
        Name string `json:"bucket_name" binding:"required"`
    }

    if err := c.ShouldBindJSON(&body); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "valid bucket_name is required"})
        return
    }

    if err := h.service.CreateBucket(c.Request.Context(), body.Name); err != nil {
        h.handleError(c, err)
        return
    }

    c.Status(http.StatusCreated)
}

func (h *Handler) ListBuckets(c *gin.Context) {
    buckets, err := h.service.ListAllBuckets(c.Request.Context())
    if err != nil {
        h.handleError(c, err)
        return
    }
    c.JSON(http.StatusOK, buckets)
}

func (h *Handler) DeleteBucket(c *gin.Context) {
    if err := h.service.DeleteBucket(c.Request.Context(), c.Query("name")); err != nil {
        h.handleError(c, err)
        return
    }
    c.Status(http.StatusNoContent)
}

func (h *Handler) EmptyBucket(c *gin.Context) {
    bucket := c.Query("bucket")
    if bucket == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "bucket parameter is required"})
        return
    }

    err := h.service.EmptyBucket(c.Request.Context(), bucket)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.Status(http.StatusNoContent)
}

func (h *Handler) handleError(c *gin.Context, err error) {
    switch {
    case errors.Is(err, ErrInvalidFileType),
        errors.Is(err, ErrBucketNameRequired):
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})

    case errors.Is(err, ErrBucketAlreadyExists):
        c.JSON(http.StatusConflict, gin.H{"error": err.Error()})

    case errors.Is(err, ErrFileNotFound):
        c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})

    case errors.Is(err, ErrOperationTimeout):
        c.JSON(http.StatusGatewayTimeout, gin.H{"error": "request timed out"})

    default:
        c.JSON(http.StatusInternalServerError, gin.H{"error": "an unexpected error occurred"})
    }
}

Technical Decisions in the Handler

  1. Multipart/form-data without “blowing up RAM”: I open the file as a stream, avoiding loading the entire content into memory.
  2. Download streaming: I create a “pipe” between S3 and the client. This makes it possible to support larger files with modest hardware.
  3. Centralized error handling: instead of scattering if err != nil across all routes, the Handler maps semantic domain errors to consistent HTTP status codes (400, 404, 409, 504, etc.).

The Entry Point (main)

The main function loads configuration, instantiates dependencies, and starts the server. This is where one of the most useful concepts for keeping projects healthy comes into play: dependency injection.

In practical terms: main creates the AWS client → passes it to the repository → passes it to the service → passes it to the handler. This keeps the system modular and easy to test.

package main

import (
    "context"
    "log/slog"
    "os"
    "time"

    // Internal packages
    appConfig "github.com/JoaoOliveira889/s3-api/internal/config"
    "github.com/JoaoOliveira889/s3-api/internal/middleware"
    "github.com/JoaoOliveira889/s3-api/internal/upload"
    "github.com/gin-gonic/gin"

    // External packages
    configAWS "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "github.com/joho/godotenv"
)

func main() {
    _ = godotenv.Load()

    cfg := appConfig.Load()

    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    r := gin.New()

    r.Use(middleware.RequestTimeoutMiddleware(cfg.UploadTimeout))
    r.Use(middleware.LoggingMiddleware())
    r.Use(gin.Recovery())

    ctx := context.Background()
    awsCfg, err := configAWS.LoadDefaultConfig(ctx, configAWS.WithRegion(cfg.AWSRegion))
    if err != nil {
        slog.Error("failed to load AWS SDK config", "error", err)
        os.Exit(1)
    }

    s3Client := s3.NewFromConfig(awsCfg)
    repo := upload.NewS3Repository(s3Client, cfg.AWSRegion)
    service := upload.NewService(repo)
    handler := upload.NewHandler(service)

    api := r.Group("/api/v1")
    {
        api.GET("/health", func(c *gin.Context) {
            c.JSON(200, gin.H{
                "status":    "healthy",
                "env":       cfg.Env,
                "timestamp": time.Now().Format(time.RFC3339),
            })
        })

        api.GET("/list", handler.ListFiles)
        api.POST("/upload", handler.UploadFile)
        api.POST("/upload-multiple", handler.UploadMultiple)
        api.GET("/download", handler.DownloadFile)
        api.GET("/presign", handler.GetPresignedURL)
        api.DELETE("/delete", handler.DeleteFile)

        buckets := api.Group("/buckets")
        {
            buckets.POST("/create", handler.CreateBucket)
            buckets.DELETE("/delete", handler.DeleteBucket)
            buckets.GET("/stats", handler.GetBucketStats)
            buckets.GET("/list", handler.ListBuckets)
            buckets.DELETE("/empty", handler.EmptyBucket)
        }
    }

    slog.Info("server successfully started",
        "port", cfg.Port,
        "env", cfg.Env,
        "region", cfg.AWSRegion,
    )

    if err := r.Run(":" + cfg.Port); err != nil {
        slog.Error("server failed to shut down properly", "error", err)
        os.Exit(1)
    }
}

Technical Highlights in main

  • Structured logging: JSON logs are more useful for indexing and searching in tools such as CloudWatch, ELK, or Datadog.
  • Chained middlewares: logging and global timeouts are applied consistently across all routes.
  • Recovery: prevents a panic from bringing down the entire API.
  • Grouping and versioning: /api/v1 makes it easier to evolve the API without breaking existing clients.

Security and configuration: managing credentials

For the API to communicate with AWS, it needs credentials. However, exposing keys in the code is a serious risk. For this reason, in local development I use environment variables (via .env), and in production the natural evolution is to use IAM Roles.

.env file (local development)

At the root of the project, we create the .env file. It is used to store server configuration and AWS secret keys.

Warning: This file must be included in your .gitignore. Never commit your keys to GitHub or any other version control system.

# Server Settings
PORT=8080
APP_ENV=development

# AWS Configuration
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=SUA_ACCESS_KEY_AQUI
AWS_SECRET_ACCESS_KEY=SUA_SECRET_KEY_AQUI

# Application Settings
UPLOAD_TIMEOUT_SECONDS=60

Obtaining Credentials in the AWS Console (IAM)

Access keys are generated in IAM. For study purposes, you can use broad permissions, but the recommended approach is to follow the principle of least privilege and restrict access to only the required bucket and actions.

Step by step:

  1. Access IAM: In the AWS Console, search for “IAM”.
  2. Create a User: Go to Users > Create user.
  3. Configuration: Define a name (e.g., s3-api-manager). It is not necessary to enable access to the AWS Management Console for this user, as it will be used only via code.
  4. Permissions (Principle of Least Privilege): Choose Attach policies directly.
    • Note: For learning purposes, you may select AmazonS3FullAccess. In a real production scenario, the ideal approach is to create a custom policy that grants access only to the specific bucket you intend to use.
  5. Generate the Keys: After creating the user, click on the user name, go to the Security credentials tab, and look for the Access keys section.
  6. Creation: Click Create access key, select the “Local code” option, and proceed.

IMPORTANT: You will see the Access Key ID and the Secret Access Key. Copy and paste them into your .env file immediately, as the Secret Key will never be shown again.

Middlewares: observability and resilience

Middlewares are the right place for global behaviors: logging, timeouts, correlation, and protection. Instead of replicating these concerns per endpoint, I centralize them.

Structured logging

The log is written after the request finishes. This makes it possible to accurately record the status code and latency, making performance and error analysis much easier.

package middleware

import (
    "log/slog"
    "time"

    "github.com/gin-gonic/gin"
)

func LoggingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        raw := c.Request.URL.RawQuery

        c.Next()

        if raw != "" {
            path = path + "?" + raw
        }

        slog.Info("incoming request",
            "method", c.Request.Method,
            "path", path,
            "status", c.Writer.Status(),
            "latency", time.Since(start).String(),
            "ip", c.ClientIP(),
            "user_agent", c.Request.UserAgent(),
        )
    }
}

Timeout control

In systems that depend on external services, leaving connections open indefinitely is risky. The timeout middleware cancels the request when it exceeds a defined limit, freeing up resources.

package middleware

import (
    "context"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
)

func RequestTimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
        defer cancel()

        c.Request = c.Request.WithContext(ctx)

        finished := make(chan struct{}, 1)

        go func() {
            c.Next()
            finished <- struct{}{}
        }()

        select {
        case <-finished:
            return
        case <-ctx.Done():
            if ctx.Err() == context.DeadlineExceeded {
                c.AbortWithStatusJSON(http.StatusGatewayTimeout, gin.H{
                    "error": "request timed out",
                })
            }
        }
    }
}

Technical Highlights in Middlewares

  • c.Next() at the right moment: ensures that logs capture the actual status code and real latency.
  • select and channels: a clear pattern to observe “completed vs. timed out,” maintaining control over the request lifecycle.
  • Structured logs: make it easier to build dashboards and alerts.

Unit Tests

One of the greatest benefits of this architecture is the ability to test business logic in isolation, without AWS and without an internet connection.

I use mocks to simulate the repository and focus on validating: bucket rules, file type validations, identifier generation, and error handling.

package upload

import (
    "context"
    "io"
    "time"

    "github.com/stretchr/testify/mock"
)

type RepositoryMock struct {
    mock.Mock
}

func (m *RepositoryMock) Delete(ctx context.Context, bucket string, key string) error {
    panic("unimplemented")
}

func (m *RepositoryMock) DeleteAll(ctx context.Context, bucket string) error {
    panic("unimplemented")
}

func (m *RepositoryMock) DeleteBucket(ctx context.Context, bucket string) error {
    panic("unimplemented")
}

func (m *RepositoryMock) GetStats(ctx context.Context, bucket string) (*BucketStats, error) {
    panic("unimplemented")
}

func (m *RepositoryMock) ListBuckets(ctx context.Context) ([]BucketSummary, error) {
    panic("unimplemented")
}

func (m *RepositoryMock) Upload(ctx context.Context, bucket string, file *File) (string, error) {
    args := m.Called(ctx, bucket, file)
    return args.String(0), args.Error(1)
}

func (m *RepositoryMock) Download(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
    args := m.Called(ctx, bucket, key)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(io.ReadCloser), args.Error(1)
}

func (m *RepositoryMock) GetPresignURL(ctx context.Context, bucket, key string, expiration time.Duration) (string, error) {
    args := m.Called(ctx, bucket, key, expiration)
    return args.String(0), args.Error(1)
}

func (m *RepositoryMock) List(ctx context.Context, bucket, prefix, token string, limit int32) (*PaginatedFiles, error) {
    args := m.Called(ctx, bucket, prefix, token, limit)
    return args.Get(0).(*PaginatedFiles), args.Error(1)
}

func (m *RepositoryMock) CheckBucketExists(ctx context.Context, bucket string) (bool, error) {
    args := m.Called(ctx, bucket)
    return args.Bool(0), args.Error(1)
}

func (m *RepositoryMock) CreateBucket(ctx context.Context, bucket string) error {
    args := m.Called(ctx, bucket)
    return args.Error(0)
}

Implementing Service Tests

In the service_test.go file, we focus on testing business rules: bucket name validation, UUID generation, and error handling.

package upload

import (
    "context"
    "strings"
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

type readSeekCloser struct {
    *strings.Reader
}

func (rsc readSeekCloser) Close() error { return nil }

func TestUploadFile_InvalidBucket(t *testing.T) {
    mockRepo := new(RepositoryMock)
    service := NewService(mockRepo)

    result, err := service.UploadFile(context.Background(), "", &File{})

    assert.Error(t, err)

    assert.Empty(t, result)

    assert.ErrorIs(t, err, ErrBucketNameRequired)
}

func TestUploadFile_Success(t *testing.T) {
    mockRepo := new(RepositoryMock)
    service := NewService(mockRepo)
    ctx := context.Background()

    content := strings.NewReader("x89PNGrnx1an" + strings.Repeat("0", 512))
    file := &File{
        Name:    "test-image.png",
        Content: readSeekCloser{content},
    }

    bucket := "my-test-bucket"
    expectedURL := "https://s3.amazonaws.com/my-test-bucket/unique-id.png"

    mockRepo.On("Upload", mock.Anything, bucket, mock.AnythingOfType("*upload.File")).Return(expectedURL, nil)

    resultURL, err := service.UploadFile(ctx, bucket, file)

    assert.NoError(t, err)
    assert.NotEmpty(t, resultURL)
    assert.Equal(t, expectedURL, resultURL)

    mockRepo.AssertExpectations(t)
}

func TestGetDownloadURL_Success(t *testing.T) {
    mockRepo := new(RepositoryMock)
    service := NewService(mockRepo)

    bucket := "my-bucket"
    key := "image.png"
    expectedPresignedURL := "https://s3.amazonaws.com/my-bucket/image.png?signed=true"

    mockRepo.On("GetPresignURL", mock.Anything, bucket, key, 15*time.Minute).
        Return(expectedPresignedURL, nil)

    url, err := service.GetDownloadURL(context.Background(), bucket, key)

    assert.NoError(t, err)
    assert.Equal(t, expectedPresignedURL, url)
    mockRepo.AssertExpectations(t)
}

Tests running on terminal

Technical Highlights in Tests

  • Test the rule, not the integration: the Service must be predictable; integrations are left for dedicated tests.
  • Security validation reflected in tests: if the API validates MIME types, the tests must respect this to remain realistic.
  • errors.Is and semantic errors: this strengthens consistency and makes assertions easier and clearer.

Automation with GitHub Actions

On every push or pull request, the CI installs dependencies and runs tests. This keeps the main branch stable and reduces the risk of regressions.

Configuring the Workflow (go.yml)

The configuration file should be created at .github/workflows/go.yml. It defines the steps required to validate the health of the project.

# .github/workflows/go.yml
name: Go CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    name: Run Tests 
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.25.5'
          cache: true

      - name: Install dependencies
        run: go mod download

      - name: Verify dependencies
        run: go mod verify

      - name: Run tests
        run: go test -v -race ./...

Test running on Github

Technical Highlights in Automation

  • Race detector (-race): important because there is concurrency (parallel uploads).
  • Dependency caching: speeds up builds and reduces execution time.
  • Secure pipeline: since the Service is tested with mocks, the CI does not require AWS credentials.

Centralized Configuration

In small projects, it is common to scatter os.Getenv calls. As projects grow, this becomes technical debt: dependencies become implicit and hard to track.

Centralizing configuration creates a “single source of truth”: validation and typing at startup, and simple consumption throughout the rest of the codebase.

package config

import (
    "os"
    "strconv"
    "time"
)

type Config struct {
    Port          string
    AWSRegion     string
    UploadTimeout time.Duration
    Env           string
}

func Load() *Config {
    return &Config{
        Port:          getEnv("PORT", "8080"),
        AWSRegion:     getEnv("AWS_REGION", "us-east-1"),

        UploadTimeout: time.Duration(getEnvAsInt("UPLOAD_TIMEOUT_SECONDS", 30)) * time.Second,
        Env:           getEnv("APP_ENV", "development"),
    }
}

func getEnv(key, defaultValue string) string {
    if value, exists := os.LookupEnv(key); exists {
        return value
    }
    return defaultValue
}

func getEnvAsInt(key string, defaultValue int) int {
    valueStr := getEnv(key, "")
    if value, err := strconv.Atoi(valueStr); err == nil {
        return value
    }
    return defaultValue
}

Semantic Errors

The Service should not return HTTP status codes. It should return errors that make sense within the domain, and the Handler decides the appropriate status. This keeps the domain reusable (HTTP today, gRPC tomorrow, CLI later).

package upload

import "errors"

var (
    ErrBucketNameRequired  = errors.New("bucket name is required")
    ErrFileNotFound        = errors.New("file not found in storage")
    ErrInvalidFileType     = errors.New("file type not allowed or malicious content detected")
    ErrBucketAlreadyExists = errors.New("bucket already exists")
    ErrOperationTimeout    = errors.New("the operation timed out")
)

Technical Highlights in Error Handling

  • Domain-agnostic design: errors remain meaningful outside of HTTP.
  • Robust comparison with errors.Is: safer than comparing strings.
  • Consistency: stable and predictable messages for API consumers.

Containerization with Docker

Containerization ensures that the API runs the same way in any environment. Here I use a multi-stage build: compilation happens in an image with the toolchain, and the final runtime image is smaller and more secure.

Optimized Dockerfile

services:
  s3-api:
    build: .
    ports:
      - "8080:8080"
    env_file:
      - .env
    restart: always

Orchestration with Docker Compose

The docker-compose.yml file simplifies application startup by managing ports and automatically loading our secrets file (.env).

# Stage 1: Build the Go binary
FROM golang:1.25.5-alpine AS builder

WORKDIR /app

# Copy dependency files
COPY go.mod go.sum ./
RUN go mod download

# Copy the source code
COPY . .

# Build the application with optimizations
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o main ./cmd/api/main.go

# Stage 2: Create the final lightweight image
FROM alpine:latest

RUN apk --no-cache add ca-certificates

WORKDIR /root/

# Copy the binary from the builder stage
COPY --from=builder /app/main .
# Copy the .env file (optional, better to use environment variables in prod)
COPY --from=builder /app/.env . 

EXPOSE 8080

CMD ["./main"]

Technical Highlights in Docker

  • SSL certificates: minimal images do not always include CA certificates; without them, HTTPS calls may fail.
  • Slim binary: build flags reduce size and speed up deployment.
  • Smart caching: copying go.mod/go.sum before the source code helps Docker reuse layers efficiently.

Useful Commands

Tests

go test ./...

Running the Project

go run cmd/api/main.go

Build the image and start the container in the background

# Build the image and start the container in the background
docker-compose up --build -d

# View application logs in real time
docker logs -f go-s3-api

# Stop and remove the container
docker-compose down

Conclusion

Building this management API for Amazon S3 was a practical way to combine a real need (automating my blog workflow) with solid learning (Go, AWS, security, observability, and architecture).

Project Links

  • Main GitHub Repository: This is the “living” repository, which may evolve and differ from what is described in this article.

  • Article Version Code: To see the project exactly as it was built and explained here, in a static state.

References

  • Go Standard Project Layout: https://github.com/golang-standards/project-layout
  • Clean Architecture (The Clean Code Blog): https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  • AWS SDK for Go v2 – S3 Developer Guide: https://aws.github.io/aws-sdk-go-v2/docs/getting-started/
  • Go Concurrency Patterns (Context Package): https://go.dev/blog/context
  • Gin Web Framework: https://gin-gonic.com/docs/
  • Testify – Testing Toolkit: https://github.com/stretchr/testify
  • Google UUID Package (v7 support): https://github.com/google/uuid
  • Errgroup Package (Concurrency): https://pkg.go.dev/golang.org/x/sync/errgroup
  • Dockerizing a Go App: https://docs.docker.com/language/golang/build-images/
  • GitHub Actions for Go: https://github.com/actions/setup-go

How to Avoid Common Pitfalls With JPA and Kotlin

This post was written together with Thorben Janssen, who has more than 20 years of experience with JPA and Hibernate and is the author of “Hibernate Tips: More than 70 Solutions to Common Hibernate Problems” and the JPA newsletter.

Kotlin and Jakarta Persistence (also known as JPA) are a popular combination for server-side development. Kotlin offers concise syntax and modern language features, while Jakarta Persistence provides a proven persistence framework for enterprise applications.

However, Jakarta Persistence was originally designed for Java. Some of Kotlin’s popular features and concepts, like null safety and data classes, help you tremendously when implementing your business logic, but they don’t align well with the specification.

This article outlines a set of best practices to help you avoid problems and build reliable persistence layers with Kotlin and Jakarta Persistence. And to share some good news before diving in, IntelliJ IDEA 2026.1 will automatically detect many of these issues, highlight them with warnings, and provide support through various inspections.

Entity class design

Jakarta Persistence defines several requirements for entity classes that form the foundation for how persistence providers manage entity objects.

An entity class must:

  • Provide a no-argument constructor
    The persistence provider uses reflection to call the no-argument constructor to create entity instances when loading data from the database.
  • Have non-final attributes
    When fetching an entity object from the database, the persistence provider sets all attribute values after it calls the no-argument constructor to instantiate the entity object. This process is called hydration.
    After that is done, the persistence provider keeps a reference to the entity object to perform automatic dirty checks, during which it detects changes and updates the corresponding database records automatically.
  • Be non-final
    The persistence provider often creates proxy subclasses to implement features such as lazy loading for @ManyToOne and @OneToOne relationships. For this to work, the entity class can’t be final.

In addition to these specification requirements, it is a widely accepted best practice to:

  • Implement equals, hashCode, and toString carefully
    These methods should rely only on the entity’s identifier and type to avoid unexpected behavior in persistence contexts. You can find approaches for better implementing those here.

These rules are easy to follow in Java but conflict with some of Kotlin’s defaults, such as final classes, immutable properties, and constructor-based initialization.

The following sections show how to adapt your Kotlin classes to meet these requirements while still using Kotlin’s language features effectively.

Data classes vs. entities

Kotlin’s data classes are designed to hold data. They are final and provide several utility methods, including getters and setters for all fields, as well as equals, hashCode, and toString.

This makes data classes a great fit for DTOs, which represent query results and are not managed by your persistence provider.

Below is a typical usage of a data class to fetch data:

data class EmployeeWithCompany(val employeeName: String, val companyName: String)

val query = entityManager.createQuery("""
   SELECT new com.company.kotlin.model.EmployeeWithCompany(p.name, c.name)
    FROM Employee e
       JOIN e.company c
    WHERE p.id = :id""")

val employeeWithCompany = query.setParameter("id", 1L).singleResult;

However, entities differ because they are managed objects. And that causes problems when you model them as a data class.

For entities, the persistence provider automatically detects changes and uses lazy loading for relationships. To support this, it expects entity classes to follow the requirements defined in the Jakarta Persistence specification, which we discussed at the beginning of this chapter. 

As you can see in the following table, that makes Kotlin’s data classes a bad fit for entity classes.

Kotlin Data Class Jakarta Persistence Entity
Class Type Final Must be open (non-final) so the provider can create proxy subclasses
Constructors Primary constructor with required parameters Must provide a no-argument constructor, used by the persistence provider
Mutability Immutable by default (val properties) Must have mutable, non-final attributes so the provider can perform lazy loading as well as detect and persist changes
equals and hashCode Use all properties Should rely only on type and primary key
toString Includes all properties Should only reference eagerly loaded attributes to avoid additional queries

The recommended approach is to use regular open classes to model your entities. They are mutable and proxy-friendly, and they don’t cause any issues with Jakarta Persistence.

@Entity
open class Person {
   @Id
   @GeneratedValue
   var id: Long? = null

   var name: String? = null
}

Non-final classes and no-argument constructors

As discussed earlier, Jakarta Persistence requires entity classes to be non-final and provide a no-argument constructor. 

Kotlin’s classes are final by default and don’t have to offer a no-argument constructor.

But don’t worry, it’s easy to fulfill the requirements without changing your code or implementing your entity classes in a specific way. Just add the no-arg and all-open plugins and add kotlin-reflect to your dependencies. This adds the required constructor and marks annotated classes as open at build time.

Currently, you need the compiler plugins plugin.spring and plugin.jpa, which will automatically add the no-arg and all-open plugins. When creating a new Spring project using the New Project wizard in IntelliJ IDEA or via start.spring.io, both plugins are automatically configured for you. And starting with IntelliJ IDEA 2026.1, this will also be the case when you add a Kotlin file to an existing Java project.

plugins {
   kotlin("plugin.spring") version "2.2.20"
   kotlin("plugin.jpa") version "2.2.20"
}

allOpen {
   annotation("jakarta.persistence.Entity")
   annotation("jakarta.persistence.MappedSuperclass")
   annotation("jakarta.persistence.Embeddable")
}

When configuring this manually, pay close attention to both parts of this setup. plugin.jpa appears to provide the required configuration, but it only configures the no-arg plugin, not the all-open one. This will be improved with the upcoming JPA plugin update. You will then no longer have to add the allOpen section. See: KT-79389

Mutability

As a Kotlin developer, you’re used to analyzing whether information is mutable or immutable and modelling your classes accordingly. And when defining your entities, you might want to do the same. But that creates potential issues.

var vs. val

In Kotlin, you use val to define an immutable field or property and var for mutable ones. Under the hood, val is compiled in Java to a final field. But as discussed earlier, the Jakarta Persistence specification requires all fields to be non-final.

So, in theory, you can’t use val when modelling your entities. However, if you look at various projects, you can find several entities that use val without causing any bugs.

@Entity
class Person(name: String) {
   @Id
   @GeneratedValue
   var id: Long? = null

   val name: String = name
}

That’s because your Jakarta Persistence implementation, the persistence provider, populates entity fields through reflection if you use field-based access, which is usually the case when implementing Jakarta Persistence entities in Kotlin. final fields can also be modified using reflection. As a result, your persistence provider can modify val fields, but this contradicts Kotlin’s immutability guarantees.

So, practically, you can use val to model immutable fields of your entity class. Still, it’s not in line with the Jakarta Persistence specification, and your fields are not as immutable as you might expect. To make it even worse, JEP 500: Prepare to Make Final Mean Final discusses introducing a warning and future changes to restrict final field modifications via reflection. This would prevent you from using val on your entity fields and break many persistence layers using Jakarta Persistence and Kotlin.

Be careful when using val for your entity fields and make sure everyone on your team understands the implications.

Starting with version 2026.1, IntelliJ IDEA will display a weak warning indicating that a val field will be modified when the persistence provider, such as Hibernate or EclipseLink, instantiates the entity object.

Access types

The Jakarta Persistence specification defines two access types that determine if your persistence provider uses getter and setter methods to access your entity’s fields or reflection.

You can define the access type explicitly by annotating your entity class with the @Access annotation. Or, as almost all development teams do, define it implicitly by where you place your mapping annotations:

  • Annotations on entity fields → field access = direct access using reflection
  • Annotations on getter methods → property access = access via getter or setter methods

Most Kotlin developers put their annotations on properties, which Hibernate treats as field access by default.

@Entity
class Company {
   @Id
   @GeneratedValue
   var id: Long? = null

   var name: String? = null
       get() {
           println("Getter called")
           return field
       }
       set(value) {
           println("Setter called")
           field = value
       }
}

In this example, it might look like the getter and setter methods will be called to access the name property. But that’s only the case for your business logic. Because we annotated the fields, the persistence provider will use reflection to access them directly, bypassing the getter and setter methods.

As a general best practice, it’s recommended to stick to field access. It’s easier to read and lets your persistence provider access the entity’s fields directly. You can then provide getter and setter methods that help your business code without affecting your database mapping.

If you want to use property access, you can either annotate your entity class with @Access(AccessType.PROPERTY) or annotate the accessors explicitly:

@Entity
class Company {
   @get:Id
   @get:GeneratedValue
   var id: Long? = null

   var name: String? = null
       get() {
           println("Getter called")
           return field
       }
       set(value) {
           println("Setter called")
           field = value
       }
}

However, when you do this, you must ensure that all fields are defined as var. Kotlin doesn’t provide setter methods for fields defined as val.

@Entity
class Company {
   @get:Id
   @get:GeneratedValue
   var id: Long? = null

   val name: String? = null 
}

You can see this when checking Kotlin’s decompiled bytecode of a snippet above.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import kotlin.Metadata;
import org.jetbrains.annotations.Nullable;


@Entity
…
public final class Company {
  @Nullable
  private Long id;
  @Nullable
  private final String name;

  @Id
  @GeneratedValue
  @Nullable
  public final Long getId() {
     return this.id;
  }

  public final void setId(@Nullable Long var1) {
     this.id = var1;
  }

  @Nullable
  public final String getName() {
     return this.name;
  }
}

Your persistence provider will check that each field has a getter and a setter method. As long as you use var to define your entity fields, property access works with Kotlin.

Null safety and default values

Null safety and default values are two popular features in Kotlin that don’t exist in that form in Java. It’s no surprise that you have to pay special attention if you want to use them in your Jakarta Persistence entities.

Nullability considerations (including primary key fields)

Kotlin allows you to define whether a field or property supports null values. Unfortunately, reflection can bypass Kotlin’s null prevention, and as explained earlier, the persistence provider uses reflection to initialize your entity objects.

Even if you define an entity attribute as non-nullable, your persistence provider will set it to null if the database contains a null value. In your business code, this can lead to runtime exceptions similar to those seen in Java.

@Entity
@Table(name = "user")
class User(
   @Id
   var id: Long? = null

   var name: String
)

fun testLogic(){
   // Suppose the row with id = 1 has name = NULL in the database
   val user = userRepository.findById(1).get()
   println("Firstname: ${user.name}") // null, because Hibernate saves null via reflection
}

And unfortunately, solving this problem is not as easy as it seems.

You could argue that all non-nullable entity fields should map to a database column with a not-null constraint. So, your database can’t contain any null values.

In general, this is a great approach. But it does not eliminate the risk completely. Constraints can get out of sync between different environments or during migrations. Therefore, using not-null constraints on your database is highly recommended, but it doesn’t provide an unbreakable guarantee that you will never fetch a null value from the database.

To make it even worse, all Jakarta Persistence implementations call the no-argument constructor of your entity class to instantiate an object and then use reflection to initialize each field. This means that technically, all your entity fields must be nullable.

What does that mean for your entities? Should you use val or var to model your fields?

That decision is ultimately up to you. Both of them work, but we recommend sticking to the Kotlin way: Use val if an entity field is not supposed to be changed by your business logic, and var otherwise. However, due to the issues discussed earlier, it is also essential to ensure that everyone on your team is aware that your Jakarta Persistence implementation may set those fields to null if your database lacks a not-null constraint.

@Id and generated value

The previous paragraphs already discussed why all entity fields should be nullable. However, many developers consider primary key attributes to be distinct because the database requires a primary key value, and the Jakarta Persistence specification defines it as immutable. Primary keys are mandatory and immutable as soon as you persist the entity object in your database. But let’s quickly discuss why this doesn’t mean that primary key values should be not-nullable, especially if you’re using database-generated primary key values.

When you want to store a new record in your database, you create a new entity object without a primary key and persist it. 

Unfortunately, the Jakarta Persistence specification doesn’t clearly define how to implement the persist operation. But it requires generating a primary key value if none is provided. The handling of provided primary key values differs across implementations, but that’s a topic for a different article. 

The important thing here is that all persistence providers treat null as a not-provided primary key value. They then use a database sequence or an auto-incremented column to generate a primary key value and set it on the entity object. Due to this mechanism, the primary key value is null before the entity gets persisted, and changes during the persist operation.

An interesting side note is that Hibernate handles the primary key value 0 differently when calling the persist or the merge method. The persist method throws an exception because it expects the object to be an already-persisted entity. In contrast, Hibernate’s merge method generates a new primary key value and inserts a new record into the database. That’s why you can model a primary key with the default value 0 and save the new entity object using Spring Data JPA. The default repository implementation recognizes the already set primary key value and calls the merge method instead of the persist method.

Now, returning to the initialization of primary key fields.

When you fetch an entity object from the database, your persistence provider uses the parameterless constructor to instantiate a new object. It then uses reflection to set the primary key value before it returns the entity object to your business code.

All of this clearly shows that the Jakarta Persistence specification expects the primary key field to be mutable, even though the primary key value is not allowed to change after it was assigned. To avoid any portability issues across different Jakarta Persistence implementations, use null to represent an undefined primary key value.

@Entity
class Company {
   @Id
   @GeneratedValue
   var id: Long? = null
}

Declaring default values

Kotlin’s support for default values can simplify your business code and prevent null values. 

@Entity
class Company(
   @Id @GeneratedValue 
   var id: Long? = null,

   @NotNull
   var name: String = "John Doe",

   @Email
   var email: String = "default@email.com"
)

However, please be aware that these default values will have no effect when your persistence provider fetches an entity object from the database.

val companyFromDb = companyRepository.findById(1).get()
println(companyFromDb.email) // <- If email in DB is empty, it will not set to "default@email.com"

The Jakarta Persistence specification requires a parameterless constructor that the implementations call when fetching an entity object from the database. After that, they use reflection to map all values retrieved from the database to the corresponding entity fields. As a result, the default values defined in your constructor will not be used, and some fields of your entity object might not be set even though you expect your constructor to assign default values. This may not cause any issues in your application, but it is something you and your team should be aware of.

Annotation placement

In Java, annotations are typically applied directly to the field, method, or class you annotate. In Kotlin, by contrast, annotations can target different elements, such as constructor parameters, properties, or fields.

Before Kotlin 2.2, this often caused problems because annotations applied to properties were applied only to the constructor parameter by default. This often caused problems for Jakarta Persistence and validation frameworks. Annotations like @NotNull, @Email, or even @Id didn’t end up where the framework expected them to be. This led to missed validations or mapping issues.

The good news is that this has been improved in Kotlin 2.2. With the new compiler option, which IntelliJ IDEA will suggest enabling, annotations will be applied to the constructor parameter and the property or field by default. So, your code now works as expected without requiring any changes.

To learn more, check out the blog post.

IntelliJ IDEA to the rescue!

In the upcoming 2026.1 release, IntelliJ IDEA will provide inspections and quick-fixes to address many of the problems mentioned in this article, thereby improving your overall experience. Be sure to update when the release becomes available. Here are a few examples of what you’ll get with the new release:

  • Highlighting missing no-arg constructors or final entity classes and suggestions to enable the correct Kotlin plugins.
  • Autoconfiguration of all essential setup when configuring Kotlin in the project.
  • Detection and quick fix for data classes and val fields on JPA-managed properties.

And other JPA-related updates!

About the author

Thorben Janssen