Derex.dev

Thinking About File Storage

There’s a funny thing that happens when you try to rebuild a system you thought you understood.

You start with confidence, convinced you’re just a few lines of code away from ā€œsolvingā€ the problem. Then, somewhere along the way, the code teaches you that the problem was never what you thought it was.

That’s exactly what happened to me this week.

I set out to build a clean, Laravel-inspired file storage system in TypeScript. The kind of thing you’d package, publish, and forget about. But somewhere between implementing visibility handling, path generators, and file streams, the work stopped being about ā€œfilesā€ — and started being about designing for reality.


🪵 The Illusion of Just ā€œSaving Filesā€

When I first wrote:

fs.writeFileSync(path, contents);

I felt like the job was done. Simple, right? Write the file, return the path. On to the next feature.

Except real systems don’t work that way.

In the real world, ā€œsaving a fileā€ has baggage:

  • Where does it live? (Path logic.)
  • Who can access it? (Visibility rules.)
  • What happens when storage moves to S3? (Abstraction.)
  • What happens when the upload is slow, or interrupted? (Resilience.)
  • How do you test it all without hardcoding assumptions? (Flexibility.)

Laravel doesn’t hide these problems. It just makes them composable. That’s the genius. And that’s the part I’ve been chasing in this rewrite.


šŸ—‚ Paths Are Strategies, Not Strings

One of the first design dead ends I hit was hardcoded file paths.

You upload an avatar? You might write:

const path = `users/${userId}/avatar.png`;

But this kind of logic spreads like weeds. Before long, it’s tangled across your codebase. Worse, it ties your logic to a single assumption: that file paths are static.

Laravel’s model taught me something smarter: treat paths as a strategy.

So I wrote:

interface PathGenerator {
  generatePath(originalName: string): string;
}

And suddenly, my logic stopped caring about hardcoded assumptions.

Now I could swap strategies:

  • DatePathGenerator: store files by upload date.
  • UserPathGenerator: store files under user directories.
  • Future-proof: write a SlugPathGenerator, a UUIDPathGenerator, whatever.

The shift is small on the surface. But underneath, it turns your storage system into something that can adapt as requirements evolve — without rewriting business logic.


šŸ” Visibility Isn’t a Folder Name — It’s a Policy

The next trap I walked into was treating public and private as if they were just folders.

But that’s not how Laravel thinks about it.

When you save a file in Laravel:

Storage::put('photos/cat.png', $contents, 'public');

you’re not just writing to a path. You’re setting an intent: this file can be shared. The implementation decides how that’s enforced — locally it might be folder-based, on S3 it might be ACLs.

So in my system, I updated my path resolver to reflect that intent:

const targetVisibility = visibility ?? this.defaultVisibility;
return path.join(this.root, targetVisibility, filePath);

Now visibility isn’t an afterthought. It’s baked into the path, enforced by design.

That might seem small. But small design decisions like this prevent big security mistakes later.


🌊 Streams: Where Happy Path Code Goes to Die

And then came the humbling part: testing file streams.

It’s easy to write:

await storage.writeStream('file.txt', readableStream);

and call it a day. But streams don’t always behave. If you don’t handle:

  • slow streams,
  • interrupted streams,
  • unclosed streams,

your tests will hang. Your production code will too.

I wrote tests that simulated:

  • normal uploads,
  • network-like latency,
  • interrupted connections.

And every time the test failed, the design got better. I added proper end and destroy calls. I added error listeners. I added timeouts.

You don’t really ā€œgetā€ streams until you see one hang your test suite at 2AM.


šŸ’” The Shift: Laravel’s Storage Model Is a Way of Thinking

When I started this project, I thought I was building a file storage system.
What I’ve realized is: I’m actually building a mental model.

Laravel’s storage isn’t just about reading and writing files — it’s about designing for:

  • change,
  • failure,
  • policy,
  • and most of all: clarity.

Week 2 wasn’t about finishing features. It was about learning to ask better questions:

  • What does the code assume?
  • What happens when those assumptions break?
  • How can design prevent human error, not just handle it?

The code will change. The model will stay.


šŸ”„ What’s Next

This week was all about:

  • Path strategies.
  • Visibility policies.
  • Testing for real-world file behavior.

Next week?
We’ll dig into:

  • Signed URLs.
  • Slow and interrupted uploads.
  • Custom path generators.
  • Edge cases that I nearly skipped — but I’m glad I didn’t.

Because the more I work on this, the more I realize:
It was never about the files.

It was always about designing for the real world.

Did I make a mistake? Please consider Send Email With Subject