The Missing Organizing Principle of Microsoft Fabric: Medallion Architecture Explained :gem:

If you’ve tried picking up Microsoft Lakehouse, Synapse Spark, Data Factory, and Power BI recently, you’ve probably felt the crushing weight of tool overload.

Most developers fall into the trap of learning these SaaS tools in isolation. But treating Fabric like a random collection of standalone apps leads to fragile pipelines, massive technical debt, and data governance nightmares.

To master Microsoft Fabric, you need the unifying framework behind it: The Medallion Architecture.

🌊 The Water Filtration Mental Model

Invented by Databricks and adopted as the modern industry standard, Medallion Architecture divides your data platform into three progressive layers of quality. Think of it like purifying water:

[Raw Order API / Sources] 
       |
       v
:third_place_medal: BRONZE (Raw Lakehouse Files) --> Raw reservoir water (Debris & mud)
       |
       v  (Synapse PySpark / Data Factory)
:second_place_medal: SILVER (Conformed Delta Tables) --> Filtered utility water (Clean & standardized SSOT)
       |
       v  (Synapse SQL / Star Schema)
:first_place_medal: GOLD (Business-Ready Analytics) --> Bottled mineral water (Direct Lake Power BI)

🛠️ How it Maps Exactly to Microsoft Fabric

1. The Bronze Layer (Raw Ingestion)

  • Goal: Immutable raw data preservation. No business logic applied.
  • Fabric Tooling: Use OneLake Shortcuts to instantly attach external S3/ADLS buckets without moving a single byte, or use Data Factory Pipelines to dump raw JSON/CSVs into the Lakehouse Files section. Keep it append-only.

2. The Silver Layer (Cleaned & Conformed)

  • Goal: Your Single Source of Truth (SSOT). Clean empty strings, enforce strict data types, and deduplicate records.
  • Fabric Tooling: Synapse Spark Notebooks running optimized PySpark scripts to save cleaned data as ACID-compliant Delta Parquet tables.

3. The Gold Layer (Business-Ready Analytics)

  • Goal: High-performance consumption organized into business subject areas (Sales, Finance, etc.) using a Star Schema.
  • Fabric Tooling: Model with Synapse Data Warehouse (T-SQL), then connect Power BI in Direct Lake mode. Direct Lake queries Delta tables straight from OneLake–zero import lag, zero duplication.

⚠️ 3 Common Beginner Mistakes to Avoid

  1. Skipping Silver: Ingesting raw data into Bronze and building Power BI reports directly off raw files. (Guaranteed dashboard breakage on schema drift!).
  2. Mixing Zones: Storing cleaned Delta tables in the same Lakehouse folder as raw CSVs. Maintain strict structural separation.
  3. Ignoring Data Modeling: Dumping flat tables straight into Power BI instead of building a clean Star Schema.

Stop building fragile, ad-hoc pipelines. Start architecting elite, governance-hardened analytics platforms!

👉 Read my complete architectural breakdown here: Microsoft Fabric Medallion Architecture Guide

Running My Tiny Docker-like Runtime on macOS with Lima

Running My Tiny Docker-like Runtime on macOS with Lima: Lessons, Mistakes, and a Simple Benchmark

When I started building my own tiny Docker-like runtime in Go, I had one simple assumption:

“It is written in Go, so I should be able to run it anywhere.”

That assumption was only half correct.

Yes, Go makes it easy to compile binaries for different platforms. But a container runtime is not just a Go application. A container runtime depends heavily on operating system features, especially Linux kernel features.

In my case, the project needed things like:

  • Linux namespaces
  • cgroups v2
  • mount isolation
  • chroot
  • process isolation
  • bridge networking
  • veth pairs
  • iptables/NAT

And that is where macOS becomes a problem.

macOS is not Linux. It does not provide Linux namespaces or cgroups in the same way. So even if my code compiled on macOS, the actual container runtime logic could not work directly on macOS.

This was the point where I started using Lima.

In this article, I want to share how I used Lima to run my tiny Docker-like runtime from macOS, the mistakes I made, the small design decisions I learned from, and a simple experimental benchmark at the end.

This is not a “Lima vs Docker Desktop” article.

It is more about understanding the boundary between macOS, Linux, Docker, and a custom container runtime.

What I Was Building

The project is a small Docker-like runtime written in Go.

The goal was not to replace Docker.

The goal was to understand what Docker does under the hood.

Docker gives us a very clean developer experience:

docker run alpine echo hello

But behind that simple command, many things happen:

image resolution
filesystem preparation
namespace creation
cgroup configuration
mount setup
network setup
process execution
log tracking
metadata storage
cleanup

The official Docker documentation describes Docker as an open platform for developing, shipping, and running applications:

https://docs.docker.com/get-started/docker-overview/

That high-level explanation is useful, but when you build a tiny runtime yourself, Docker becomes much less magical.

You start seeing the lower-level Linux pieces.

For example, my runtime supports commands like:

tiny-docker-go run --rootfs ./rootfs/alpine /bin/sh
tiny-docker-go ps
tiny-docker-go logs -f <container-id>
tiny-docker-go stop <container-id>

On Linux, this makes sense.

On macOS, it immediately raises a question:

Where do Linux namespaces and cgroups come from?

The answer is: they do not come from macOS.

I needed Linux.

The First Mistake: Thinking Go Portability Means Runtime Portability

My first mistake was confusing language portability with operating system feature portability.

I was thinking like this:

Go can build on macOS.
Therefore, my runtime should work on macOS.

But the correct mental model is:

Go can compile the program for macOS.
But Linux container primitives still require Linux.

A Go program can be cross-platform.

But this does not mean every syscall or kernel feature exists on every platform.

For example, when a container runtime wants to isolate a process, it may need Linux-specific features like:

CLONE_NEWUTS
CLONE_NEWPID
CLONE_NEWNS
CLONE_NEWNET
cgroup filesystem
mount operations
veth networking
iptables rules

These are not available as normal Linux container primitives on macOS.

So the real problem was not the programming language.

The real problem was the kernel.

That was a very important lesson for me.

Why macOS Cannot Run This Directly

macOS uses the XNU kernel.

Linux containers depend on the Linux kernel.

This matters because containers are not virtual machines. A container is usually a regular process with a restricted view of the system.

That restricted view is created by kernel features.

For example:

PID namespace      -> gives the process its own process tree
UTS namespace      -> gives the process its own hostname
mount namespace    -> gives the process its own mount view
network namespace  -> gives the process its own network stack
cgroups            -> limit and track resource usage
chroot/rootfs      -> changes the visible filesystem root

On a Linux machine, a runtime can call these features directly.

On macOS, the features are not available in the same way.

So the architecture had to change.

Instead of this:

macOS
  -> tiny-docker-go
      -> Linux namespaces/cgroups

I needed this:

macOS
  -> Linux VM
      -> tiny-docker-go
          -> Linux namespaces/cgroups

This is where Lima became useful.

What Is Lima?

Lima is a tool that runs Linux virtual machines on macOS.

Official documentation:

https://lima-vm.io/docs/

Installation guide:

https://lima-vm.io/docs/installation/

The important thing is this:

Lima is not Docker.
Lima is not my container runtime.
Lima gives me a Linux VM.

That Linux VM gives my project access to the Linux kernel features it needs.

A simple mental model:

MacBook
  └── macOS
       └── Lima VM
            └── Linux
                 └── my tiny Docker-like runtime
                      └── container-like process

This separation helped me understand the problem much better.

Lima is the environment.

My Go runtime is the thing doing the container work.

Alpine rootfs is the container filesystem.

Why I Did Not Just Use Docker Desktop

Docker Desktop is great.

I use Docker Desktop for normal development work.

But for this project, Docker Desktop was not the cleanest learning environment.

Docker Desktop itself uses a Linux VM behind the scenes on macOS. That is how Docker can run Linux containers on macOS.

But I was not trying to simply run containers.

I was trying to build a small runtime that behaves like a container runtime.

So if I put everything behind Docker Desktop too early, I would hide some of the details I wanted to learn.

My goal was not:

How do I run an app in Docker?

My goal was:

How does a container runtime use Linux features to isolate a process?

For that goal, Lima felt cleaner.

The distinction became:

Docker Desktop:
  Great for running Docker containers and application stacks.

Lima:
  Great for getting a Linux environment on macOS and experimenting with Linux internals.

So for this project, Lima gave me a better learning path.

Installing Lima

On macOS, installing Lima with Homebrew is simple:

brew install lima

Then I created a VM for the project:

limactl start --name=tiny-docker --cpus=4 --memory=4 --disk=20

Then I entered the VM:

limactl shell tiny-docker

or sometimes simply:

lima

Inside the VM, I installed the Linux packages my runtime needed:

sudo apt update
sudo apt install -y golang-go curl tar iproute2 iptables

Each dependency had a reason:

golang-go  -> build and test the runtime
curl       -> download rootfs archives
tar        -> extract rootfs archives
iproute2   -> work with Linux networking
iptables   -> configure NAT for isolated networking

This was already a useful learning point.

A container runtime is not just one binary.

It also depends on Linux system capabilities and tools, especially if you are implementing networking.

Preparing the Root Filesystem

A container needs a filesystem.

Docker normally handles this using images and layers.

My project was simpler. I used an Alpine minirootfs.

Inside the Lima VM:

mkdir -p rootfs/alpine

ARCH=$(uname -m)

curl -L -o alpine-rootfs.tar.gz 
  "https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/${ARCH}/alpine-minirootfs-3.23.4-${ARCH}.tar.gz"

sudo tar -xzf alpine-rootfs.tar.gz -C rootfs/alpine

Then I could run:

sudo ./tiny-docker-go run --rootfs ./rootfs/alpine /bin/sh

At this point, the rootfs became the filesystem that the process sees inside the container-like environment.

This made the concept very concrete for me.

Before this project, I mostly thought about Docker images.

After this project, I started thinking more clearly about root filesystems.

A simplified version is:

Docker image:
  A packaged filesystem with metadata and layers.

Rootfs:
  The actual filesystem view used by the container process.

My tiny runtime does not implement Docker image layers, registries, manifests, or OCI image pulling.

It simply uses an extracted root filesystem.

That is enough for learning.

Architecture: macOS as a Wrapper, Linux as the Runtime

After experimenting, I ended up with this architecture:

If host is Linux:
  run the runtime directly.

If host is macOS:
  route the command through Lima.
  execute the Linux binary inside the Lima VM.

From the user’s perspective, I wanted the command to still feel simple:

tiny-docker-go run --rootfs ./rootfs/alpine /bin/sh

But internally, on macOS, it becomes closer to:

limactl shell tiny-docker sudo ./bin/tiny-docker-go-linux-amd64 
  run 
  --rootfs ./rootfs/alpine 
  /bin/sh

So the macOS binary acts more like a dispatcher.

The actual runtime work happens in Linux.

A simplified architecture:

macOS terminal
  |
  | tiny-docker-go run --rootfs ./rootfs/alpine /bin/sh
  v
Darwin service layer
  |
  | limactl shell tiny-docker sudo Linux binary ...
  v
Lima VM
  |
  v
Linux runtime binary
  |
  v
namespaces + cgroups + chroot + networking

This design helped me keep a clean boundary.

macOS handles the developer command.

Linux handles the container primitives.

Building the Linux Binary

One mistake I made was forgetting that the binary inside Lima must match the Linux VM architecture.

For example, if the Lima VM is x86_64, I can build:

mkdir -p bin

GOOS=linux GOARCH=amd64 
  go build -o bin/tiny-docker-go-linux-amd64 ./cmd/tiny-docker-go

But if the Lima VM is aarch64, for example on Apple Silicon, I should build:

mkdir -p bin

GOOS=linux GOARCH=arm64 
  go build -o bin/tiny-docker-go-linux-arm64 ./cmd/tiny-docker-go

To check the VM architecture:

limactl shell tiny-docker uname -m

Possible outputs:

x86_64   -> GOARCH=amd64
aarch64  -> GOARCH=arm64

If you build the wrong architecture, you may see:

exec format error

This error is simple but confusing when you first see it.

It usually means:

The binary architecture does not match the machine trying to execute it.

This was one of those small details that reminded me how important platform boundaries are.

Mistake: Assuming Host Paths Always Exist Inside Lima

Another mistake was around file sharing.

My project existed on macOS at something like:

/Users/amir/Desktop/tiny-docker

I expected that path to always work inside Lima.

Sometimes it did.

Sometimes the mount configuration was not what I expected.

So if I ran this inside the VM:

cd /Users/amir/Desktop/tiny-docker

and got:

No such file or directory

the problem was not my Go code.

It was not the runtime.

It was simply a shared folder issue.

The lesson was:

Always verify that the path exists inside the VM, not only on the host.

Useful checks:

limactl shell tiny-docker pwd
limactl shell tiny-docker ls -la /Users
limactl shell tiny-docker ls -la /Users/amir/Desktop

If the project is not mounted, the easiest workaround is to clone the repository inside the VM:

git clone <your-repo-url>
cd tiny-docker

The cleaner long-term solution is to configure Lima mounts properly.

But the important lesson is that a VM has its own filesystem view.

Never assume the host path exists inside the guest.

Mistake: Running the Wrong Binary

Another mistake was running the macOS binary when I actually needed the Linux binary.

This is easy to do when you have files like:

./tiny-docker-go
./bin/tiny-docker-go-linux-amd64
./bin/tiny-docker-go-linux-arm64

The macOS binary can be useful as a CLI wrapper.

But the Linux binary must perform the real runtime operations.

The separation became:

macOS binary:
  command parsing
  platform detection
  Lima dispatching

Linux binary:
  namespaces
  cgroups
  chroot
  mount setup
  networking
  process lifecycle

This made the code easier to reason about.

On macOS, I do not pretend to support Linux container primitives directly.

I route the work to the Linux VM.

Mistake: Not Checking Prerequisites Early

At first, failures happened too late.

For example, I could run a command and only later discover:

limactl is not installed
Lima instance does not exist
Lima instance is not running
Linux binary is missing
Rootfs is not accessible inside Lima

This created confusing errors.

So I started adding validation before running the actual command.

Good prerequisite checks include:

Is limactl installed?
Does the Lima instance exist?
Is the Lima instance running?
Does the Linux binary exist?
Is the Linux binary accessible inside Lima?
Is the rootfs path accessible inside Lima?

This improves developer experience a lot.

Instead of a low-level error, I want an error like:

Linux binary not found at "./bin/tiny-docker-go-linux-amd64";
build it first and share it with Lima.

or:

rootfs "./rootfs/alpine" is not accessible inside Lima;
ensure the workspace is shared with the VM.

This is not the most exciting part of a runtime project.

But it is an important engineering detail.

As a senior engineer, I have learned that good error messages are part of the product.

Even if the product is just a learning project.

Example: Running a Shell

After preparing the rootfs and building the runtime, I can run:

sudo ./tiny-docker-go run --rootfs ./rootfs/alpine /bin/sh

Inside the shell:

cat /etc/os-release

Example output:

NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.23.4

Check the hostname:

hostname

Run a process:

sleep 30

From another terminal:

sudo ./tiny-docker-go ps

Example output:

ID            STATUS   PID   CREATED              COMMAND
ab12cd34ef56  running  1234  2026-05-17 12:30:45  /bin/sh

This helped me understand process tracking better.

A container runtime does not only start processes.

It also needs to track them, store metadata, collect logs, stop them, and clean up after them.

Example: Running From macOS Through Lima

From macOS, I wanted a command like this:

./tiny-docker-go run --rootfs ./rootfs/alpine /bin/echo "hello from linux"

Internally, the command is routed through Lima:

limactl shell tiny-docker sudo ./bin/tiny-docker-go-linux-amd64 
  run 
  --rootfs ./rootfs/alpine 
  /bin/echo "hello from linux"

Expected output:

hello from linux

This gave me a nice workflow.

I could stay in my macOS terminal, but still execute the real Linux runtime inside the VM.

Example: Testing a Memory Limit

If cgroup v2 is available and the runtime supports memory limits, I can run:

sudo ./tiny-docker-go run 
  --memory 128m 
  --rootfs ./rootfs/alpine 
  /bin/sh

Inside the container-like shell:

cat /sys/fs/cgroup/memory.max

Expected output:

134217728

That is 128 MiB in bytes.

This small test made cgroups much more real for me.

Before this project, a memory limit felt like a Docker CLI option:

docker run --memory 128m alpine

After implementing a small version, I started seeing it differently:

Docker exposes a nice option.
The Linux kernel enforces the limit through cgroups.

That is a very different level of understanding.

Example: Isolated Networking

For networking, the runtime can create a basic isolated mode using Linux networking primitives.

The rough idea is:

host bridge
  |
  veth pair
  |
container network namespace

Run:

sudo ./tiny-docker-go run 
  --net isolated 
  --rootfs ./rootfs/alpine 
  /bin/sh

Inside the shell:

ip addr
ip route

Depending on the implementation, I expect to see a container-side interface and a default route.

This was one of the most interesting parts for me.

Docker networking feels simple from the outside:

docker run nginx

But underneath, there is a lot of Linux networking:

network namespaces
veth pairs
bridges
routes
iptables
NAT

Building even a small version made me appreciate how much complexity Docker hides.

What I Learned About chroot

My runtime uses chroot as a simple way to change the visible root filesystem.

For learning, this is useful.

But chroot is not the same as a full production container filesystem model.

With chroot, the process sees a different root directory:

Before:
/

After:
./rootfs/alpine becomes /

But production runtimes usually involve more advanced concepts:

pivot_root
overlay filesystems
image layers
OCI runtime spec
capability dropping
seccomp
AppArmor
SELinux
user namespaces
read-only mounts
masked paths

So I try to be careful when describing the project.

It is better to say:

This is a tiny educational runtime.
It demonstrates some basic container building blocks.
It is not a production replacement for Docker, containerd, or runc.

That honesty matters.

Learning projects are valuable, but they should not be oversold.

My Updated Mental Model of Docker

Before this project, I mostly used Docker at the command level:

docker build
docker run
docker ps
docker logs
docker stop

After building a tiny runtime, I started seeing Docker in layers:

Docker CLI
Docker daemon
containerd
runc
Linux namespaces
Linux cgroups
root filesystem
network namespace
mount namespace
process lifecycle

A command like this:

docker run alpine echo hello

looks small.

But conceptually, it involves:

resolving the image
downloading layers
preparing the root filesystem
creating namespaces
configuring cgroups
setting up mounts
configuring networking
starting the process
attaching stdio
tracking metadata
collecting exit status
cleaning up resources

My tiny runtime only implements a small part of this.

But that small part was enough to make Docker feel less like magic.

Lima vs Docker Desktop: My Practical Conclusion

I do not see Lima and Docker Desktop as direct replacements for each other in every situation.

For normal application development, Docker Desktop is usually more convenient.

It gives me:

Docker CLI
Docker Compose
image management
container lifecycle management
volume support
networking
developer-friendly tooling

But for learning Linux container internals, Lima gave me a cleaner mental model.

Lima gave me:

a Linux VM
direct access to Linux tools
a clean environment for experiments
less abstraction around Docker itself

So my conclusion is:

Use Docker Desktop when your goal is to run and ship applications.
Use Lima when your goal is to understand or control the Linux environment.

For this project, Lima was the better learning tool.

Simple Experimental Benchmark

This benchmark is not scientific.

I only wanted to understand the rough overhead of routing commands from macOS through Lima compared with running directly inside the Lima VM.

The command I tested was intentionally small:

/bin/echo hello

Because the command is tiny, the overhead of the runtime and VM boundary becomes easier to notice.

Test 1: Running Directly Inside Lima

Inside the Lima VM:

time sudo ./tiny-docker-go run --rootfs ./rootfs/alpine /bin/echo hello

Example result:

hello

real    0m0.045s
user    0m0.008s
sys     0m0.020s

Test 2: Running From macOS Through Lima

From macOS:

time ./tiny-docker-go run --rootfs ./rootfs/alpine /bin/echo hello

Internally, this routes through something like:

limactl shell tiny-docker sudo ./bin/tiny-docker-go-linux-amd64 
  run 
  --rootfs ./rootfs/alpine 
  /bin/echo hello

Example result:

hello

real    0m0.180s
user    0m0.020s
sys     0m0.030s

Benchmark Interpretation

The direct Linux execution was faster.

The macOS-to-Lima path had extra overhead because the command crossed the VM boundary through limactl shell.

In this rough experiment:

Direct inside Lima:       ~45 ms
macOS through Lima:       ~180 ms
Extra routing overhead:   ~135 ms

For very short commands like /bin/echo, the overhead is visible.

For long-running processes, the overhead matters much less.

For example, if I run a service for 10 minutes, an extra 100-200 ms at startup is not very important.

My practical conclusion:

For a nice macOS developer experience, routing through Lima is acceptable.
For tight benchmark loops, run directly inside the VM.
For production-grade runtimes, this approach is educational, not final.

Things I Would Improve Next

There are many things I would like to improve in this project.

Some of them are runtime-related:

use pivot_root instead of only chroot
improve cgroup v2 handling
support better cleanup
add more robust metadata storage
improve log streaming
support better TTY handling
add user namespace support
drop Linux capabilities
add seccomp profiles

Some of them are macOS/Lima-related:

detect Lima architecture automatically
choose the correct Linux binary automatically
improve Lima instance setup
validate shared paths more clearly
provide a bootstrap command for macOS users
make error messages more actionable

A better macOS setup command could eventually look like this:

tiny-docker-go setup lima

And it could handle:

checking limactl
creating the Lima instance
building the Linux binary
preparing the rootfs
validating mounts
testing a hello-world container

That would make the project much easier to try.

Final Thoughts

Lima helped me understand the boundary between macOS and Linux much better.

The biggest lesson was simple:

Go can make the binary portable, but it cannot make Linux kernel features exist on macOS.

For a container runtime, the kernel matters.

My final mental model is:

macOS is my workstation.
Lima gives me a Linux VM.
The Linux VM gives me namespaces and cgroups.
My Go runtime uses those Linux features.
The rootfs gives the process its filesystem.

This project also changed how I look at Docker.

Docker is not magic.

But Docker is impressive because it hides a lot of complexity behind a simple interface.

A command like this:

docker run alpine echo hello

is easy to type.

But behind it, there are many layers of runtime, filesystem, networking, isolation, and process management.

Explorando el éxito musical en Spotify con Python

Explorando el éxito musical en Spotify con Python

En este proyecto realicé un análisis exploratorio de datos utilizando Python, Pandas y técnicas de visualización sobre un dataset de Spotify obtenido desde Kaggle.

El objetivo principal fue analizar diferentes características musicales y estudiar su relación con la popularidad de las canciones dentro de Spotify.

Tecnologías utilizadas

  • Python
  • Pandas
  • NumPy
  • Matplotlib
  • Jupyter Notebook

Análisis realizado

Durante el proyecto se realizaron diferentes procesos de análisis exploratorio de datos:

  • Exploración del dataset
  • Análisis de valores nulos
  • Visualización de datos
  • Análisis de géneros musicales
  • Correlaciones entre variables
  • Clasificación de canciones según popularidad

Resultados

El análisis permitió identificar diferentes patrones relacionados con la popularidad musical. Algunas variables como la danceability y la energía mostraron cierta relación con el éxito de las canciones dentro del dataset.

Además, los gráficos y visualizaciones facilitaron la comprensión de las características musicales presentes en Spotify.

Aprendizajes

Este proyecto me permitió reforzar conocimientos relacionados con Python, manipulación de datos, visualización y análisis exploratorio utilizando herramientas fundamentales dentro de Data Science.

GitHub Repository

https://github.com/MariaAngelesCiobanu/Master-DataScience-Evolve-MariaAngelesCiobanu.git

Proyecto académico desarrollado durante el Máster en Data Science de Evolve.

🔎Do You ACTUALLY Need NgRx? (Or Are You Solving the Wrong Problem?)

Most Angular apps don’t have a state-management problem. They have a state-ownership problem.

In enterprise Angular projects, the pattern is almost always the same:

A team starts a project. Someone says, “we’ll need state management eventually.”

NgRx gets added on day one.

Six months later, they’re maintaining 400+ lines of boilerplate — actions, reducers, effects, selectors — just to manage a loading spinner and a modal toggle.

This isn’t an NgRx problem. It’s an ownership problem.

🚩Ownership defines architecture. Without it, even the best tools become unnecessary complexity.

📚 Table of Contents

# Section
1 The Real Question Isn’t “Which Library?”
2 What Signals Actually Changed
3 The State Spectrum (Tool-Agnostic)
4 When You DON’T Need a Global Store
5 When NgRx Is Actually Justified
6 The Blast Radius Framework
7 The Senior Developer’s Rule
8 The Modern Angular Answer (Hybrid Model)
9 Signals vs. Store: A Balanced Discussion
10 Enterprise Reality Check
11 What I Apply as an Architect
12 Let’s Discuss
13 Further Reading

The Real Question Isn’t “Which Library?”

The Real Question Isn’t “Which Library?”

It’s “Who owns this state?”

Most teams reach for a global store before they understand their state boundaries. They assume “reactive” means “global.” It doesn’t.

Angular Signals fundamentally changes this conversation.

What Signals Actually Changed

Before Signals, even local state was awkward. You’d reach for a BehaviorSubject, expose an observable, subscribe somewhere, handle takeUntil cleanup. It worked — but it was ceremonial.

Now:

// That's it. Reactive. Zero ceremony.
const count = signal(0);
const doubled = computed(() => count() * 2);

// Update
count.update(n => n + 1);

Two lines. No subscription management. No boilerplate.

Your modal state, filter toggles, tab selection, loading indicators — all handled. Locally. Elegantly.

“Signals gave us the ability to start simple and add complexity only when boundaries prove insufficient.”

The State Spectrum (Tool-Agnostic)
Not all states are created equal. Before choosing a tool, define the scope:

Scope Ownership Angular Solution
Local Component-owned. Lives and dies with the component. signal() + computed()
Shared Service-managed. Multiple components in the same feature. Injectable service + Signals
Global Cross-feature. Event-sourced. Auditable. NgRx (SignalStore or full)

The mistake is treating everything as global by default.

When You DON’T Need a Global Store
✅ Modal visibility
✅ Filter selections
✅ Tab active state
✅ Loading indicators
✅ Form field state
✅ Pagination cursor
✅ Local UI preferences

None of these need NgRx. None of them ever did. Signals just made that obvious.

When NgRx Is Actually Justified
Let me be clear: NgRx still matters. Just not for everything.

You should consider NgRx when:

  • 🔄 Complex multi-step workflows — checkout flows, multi-stage forms, wizard-style processes.
  • 📋 Auditability requirements — compliance needs every state change logged and replayable.
  • 👥 Distributed team boundaries — multiple teams writing to the same domain with clear contracts.
  • ⚡ Event-heavy orchestration — actions as the single source of truth across features.
  • 🐛 Time-travel debugging — when you genuinely need to replay state changes.

What NgRx gives you at scale:

-➡️ Actions as documented contracts.
-➡️ Reducers as pure, predictable transformations.
-➡️ Effects for side-effect isolation.
-➡️ DevTools for distributed debugging.
-➡️ Feature state isolation via modules.

The Blast Radius Framework
When deciding on state architecture, ask one question:

“What’s the blast radius of this state change?”

Blast Radius Solution
1 component affected signal() locally
1 feature (3–5 components) Service + Signals
Multiple features / teams NgRx SignalStore
Cross-app events + compliance Full NgRx

This removes opinion from the decision and replaces it with architecture logic.

The Senior Developer’s Rule

State complexity should justify architecture complexity. Never the reverse.

If your state-management setup is harder to explain than the business problem it solves, you’ve already shipped the wrong answer.

Don’t scale your tooling faster than your app scales.

The Modern Angular Answer (Hybrid Model)
It’s not “NgRx vs. Signals.”

It’s Signals locally, services for shared scope, NgRx for organizational scale.

◼️ signal() — Local Component State (Simplest)


// LOCAL: Component state with signals
@Component({...})
export class DashboardComponent {
  activeTab = signal(0);
  filtersOpen = signal(false);
}

// modal.component.ts — No NgRx needed here
@Component({
  selector: 'app-modal',
  standalone: true
})
export class ModalComponent {
  // ✅ Local state — stays local
  protected isOpen = signal(false);
  protected title = signal('');

  // ✅ Derived state — automatic reactivity
  protected headerClass = computed(() =>
    `modal-header ${this.isOpen() ? 'active' : 'hidden'}`
  );

  open(title: string) {
    this.title.set(title);
    this.isOpen.set(true);
  }

  close() {
    this.isOpen.set(false);
  }
}

◼️ Service-based Shared State (Mid-tier)

// SHARED: Service-scoped signals
@Injectable({
  providedIn: 'root'
})
export class UserPreferencesService {
  // ✅ Private write, public read
  private _theme = signal<Theme>('light');
  private _language = signal<string>('en');

  // ✅ Public signals (read-only surface)
  theme = this._theme.asReadonly();
  language = this._language.asReadonly();

  // ✅ Derived computed state
  isDark = computed(() => this._theme() === 'dark');

  setTheme(t: Theme) {
    this._theme.set(t);
  }

  setLanguage(l: string) {
    this._language.set(l);
  }
}

◼️ NgRx SignalStore — Scalable Domain State (Enterprise)

// GLOBAL: NgRx SignalStore for enterprise scale
// order.store.ts — When NgRx is justified
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';

type OrderState = {
  orders: Order[];
  selectedId: string | null;
  loading: boolean;
};

export const OrderStore = signalStore(
  withState<OrderState>({
    orders: [],
    selectedId: null,
    loading: false
  }),
  withComputed(({ orders, selectedId }) => ({
    selectedOrder: computed(() =>
      orders().find(o => o.id === selectedId()) ?? null
    ),
    pendingCount: computed(() =>
      orders().filter(o => o.status === 'pending').length
    ),
  })),
  withMethods((store, orderService = inject(OrderService)) => ({
    async loadOrders() {
      patchState(store, { loading: true });
      const orders = await orderService.getAll();
      patchState(store, { orders, loading: false });
    },
  }))
);

◼️ computed() — Derived State Pattern (Reactive)


// cart.component.ts — Derived state without manual subscriptions
@Component({
  standalone: true
})
export class CartComponent {
  private items = signal<CartItem[]>([]);
  private discount = signal(0);

  // ✅ All derived from signals — always in sync
  subtotal = computed(() =>
    this.items().reduce((sum, i) => sum + i.price * i.qty, 0)
  );
  discountAmt = computed(() => this.subtotal() * this.discount());
  total = computed(() => this.subtotal() - this.discountAmt());
  isEmpty = computed(() => this.items().length === 0);
  itemCount = computed(() =>
    this.items().reduce((n, i) => n + i.qty, 0)
  );
}

◼️ Hybrid — Signals Local + NgRx Global (Architecture)


// checkout.component.ts — Hybrid architecture pattern
@Component({
  standalone: true
})
export class CheckoutComponent {
  // ✅ Global: complex order domain → NgRx
  private orderStore = inject(OrderStore);
  selectedOrder = this.orderStore.selectedOrder; // Signal from store

  // ✅ Local: UI-only state → Signals
  protected activeStep = signal(1);
  protected isReviewing = signal(false);

  // ✅ Bridge: derived from both worlds
  protected canConfirm = computed(() =>
    this.activeStep() === 3 && !!this.selectedOrder() && this.isReviewing()
  );
}

Signals vs. Store: A Balanced Discussion
This isn’t about picking a winner. It’s about picking the right tool for the job.

Aspect Signals + Services NgRx Store
Learning curve Minimal Steep
Boilerplate Near zero High
DevTools Limited Excellent
Audit trails Manual Built-in
Team boundaries Convention Enforced
Cross-domain events Complex Native
Performance Granular Predictable

Use Signals when:

  • State is a component/feature local
  • Team understands reactive boundaries
  • No audit requirements
  • Simple to moderate complexity

Use NgRx when:

  • Multiple teams write to the same state
  • Compliance needs action logging.
  • Complex cross-domain workflows.
  • Time-travel debugging provides value.

Enterprise Reality Check
Large Angular systems have real needs that Signals alone cannot address at team-scale:

  • Predictable workflows across features
  • Ownership boundaries between teams
  • Debugging visibility across deployment environments
  • Scalable orchestration for complex event flows

NgRx addresses these organizational problems — not just technical ones.

The mistake is importing this complexity before the organization needs it.

What I Apply as an Architect
Start simple. Escalate when complexity demands it. Never reverse this order.

Default to signal() + computed() for component-local state

Use injectable services with Signals for feature boundaries

Add ComponentStore or SignalStore when patterns repeat

Reach for full NgRx only when organizational scale justifies it

The best Angular state management is the one you don’t notice. If new developers ask about your store setup before understanding the business domain, you probably overengineered it.

Signals gave us a gift: the ability to start simple and add complexity only when boundaries prove insufficient.

Use that gift wisely.

Let’s Discuss
What’s the FIRST sign your Angular app actually needs a global state library?

Drop your answer below. Let’s build an architecture checklist together.

Possible answers:

🔄 Multiple teams writing to the same state
📊 Audit and compliance requirements
🐛 Time-travel debugging needs
👥 Team coordination overhead

Further Reading
Angular Signals Guide
NgRx SignalStore Documentation

Found this useful? Follow for more Angular architecture insights.

📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.

🌐 Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:

🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.

System Design: Shared Cookie Authentication for Multiple Apps

System Design: Shared Cookie Authentication Flow

Building a great product often means scaling your internal tooling. But as a company grows, a common architectural headache emerges: every single internal application from the Cashier dashboard to the Point of Sale (POS) system ends up with its own isolated login system.

Before you know it, managing user credentials, sessions, and multi-app permissions becomes an absolute nightmare for both users and the security team.

A few years ago, I tackled a project to solve exactly this by centralizing authentication across multiple internal applications. The goal was to transition the entire ecosystem into a single, unified system using a central SSO Engine & IAM Service to handle complex, multi-app roles and permissions.

Note: I’m sharing this architectural breakdown and diagram because I’ve already received explicit permission to do so! 😉

Architectural Flow Diagram

Below is the complete sequence flow mapping out how the authentication state behaves across different subdomains.

System Design: Shared Cookie Authentication Flow

Figure 1: High-level overview of the request routing between Domain 1 (Cashier), Domain 2 (POS), and the central Identity Provider.

How the Data Flows

When mapping out this architecture, we compared two main approaches: Full Token Callback Flow (OAuth2/OIDC style) and Shared Wildcard Cookies. Because we were operating in a controlled, purely internal ecosystem sharing the exact same root domain (*.domain.com), we opted for the Wildcard Cookie route.

Here is exactly how it works under the hood:

1. The Initial Authentication (Domain 1: Cashier)

When a user first lands on cashier.domain.com:

  1. The Frontend (FE) browser checks for existing cookie validity.
  2. If no valid authentication exists, the frontend triggers a redirect to the SSO Login Page.
  3. The user inputs their credentials. The login page hands this over to the Cashier Backend API.
  4. The Backend API acts as a client, requesting authentication from the central SSO Engine & IAM Service.
  5. Once verified, the SSO Engine returns all user permissions, metadata, and the session token.
  6. The Magic Step: The Cashier Backend API issues a Set-Cookie header to the browser. Instead of scoping it strictly to cashier.domain.com, it sets the cookie domain attribute to the wildcard root: .domain.com.

2. Seamless Cross-Domain Access (Domain 2: POS)

Now, when the same user navigates to pos.domain.com:

  1. Because the cookie was set at the root .domain.com level, the browser automatically includes the cookie in requests heading to the POS Frontend and Backend API.
  2. The POS Backend API captures this incoming cookie.
  3. To ensure strict validation, integrity, and up-to-date permissions, the Backend API securely forwards the cookie along with an app_key and secret_key directly to the central SSO Engine & IAM Service.
  4. The SSO engine verifies the keys and the cookie, returning the specific permissions and tokens required for the POS domain.
  5. The session is seamlessly established without a single redirect or manual login prompt.

Why Wildcard Cookies? (Pros & Cons)

While a Full Token Callback Flow (using authorization codes and state parameters) is the gold standard for public-facing third-party apps, it introduces significant engineering overhead, constant browser redirects, and callback route handling for every internal app you build.

For our specific use case, Shared Wildcard Cookies offered distinct advantages:

The Pros:

  • Zero Redirect Fatigue: Users transition between cashier. and pos. instantly. No jarring page blinks or auth redirects.
  • Radical Simplicity: Frontend applications don’t need complex token-storage mechanisms (like dealing with localStorage vs sessionStorage XSS trade-offs). They simply let the browser do what it does best: manage cookies.
  • Centralized Cryptographic Trust: By enforcing that every backend API must validate the cookie alongside an app_key and secret_key against the central SSO engine, we eliminated the risk of downstream microservices blindly trusting stale data.

The Trade-offs to Keep in Mind:

  • Tight Domain Coupling: This strategy strictly requires your apps to live under the same top-level domain. If your app moves to completely-different-domain.com, this mechanism breaks, and you’ll have to implement standard token exchange.
  • CSRF Mitigation is Mandatory: Because cookies are sent automatically by the browser, you must implement robust Cross-Site Request Forgery (CSRF) defenses, ensure HttpOnly and Secure flags are strictly enforced, and set proper SameSite policies (Lax or Strict depending on your cross-subdomain API interactions).

Conclusion

When building internal tooling, over-engineering can be just as dangerous as under-engineering. Choosing a Shared Wildcard Cookie flow backed by a robust, server-to-server app_key verification loop allowed us to ship a highly secure, centralized IAM ecosystem with minimal architectural friction.

How I designed the SDLC state machine for agentic coding

The architecture page on the site shows the state machine as a clickable diagram. This post walks through the design decisions that produced that shape, including the alternatives I tried and abandoned.

The pipeline, in one diagram

$ init
  └─ archetype-detect
       └─ architect          (ARCH.md)
            └─ ⚐ gate: plan         ← human approval #1
                 └─ pm              (decompose, parallelism plan)
                      └─ senior-dev × N   (parallel git worktrees)
                           └─ specialist review × 5  (parallel)
                                └─ ⚐ gate: ship       ← human approval #2
                                     └─ devops        (merge, deploy)
                                          └─ l3-support
                                               ↘ (incident pattern)
                                                  continuous-learner
                                                       ↗ (inject lesson into next architect run)

Eight runtime stages. Two human gates. One out-of-band feedback path (the dashed loop through continuous-learner). Every node maps to a single specialist agent.

Decision 1: number of human gates

I started with seven gates: plan, design-review, security-review, qa-review, performance-review, compliance-review, ship.

Every early user complained: “this is just waterfall, but now I am reviewing AI outputs instead of writing code.” Each gate added 5-15 minutes of human reading. Seven gates × 10 minutes = 70 minutes of attention per feature. Worse than manual.

Two gates works because they are at the right scope-vs-quality break points:

  • gate: plan controls scope. You decide what gets built before any code is written. This is the cheapest possible decision in the pipeline — if scope is wrong, fixing it costs zero.
  • gate: ship controls quality. You see all 5 reviewer verdicts at once, with rationale per reviewer. You either approve, or push back on a specific reviewer (which the system re-runs).

Everything in between is the agents’ problem. If reviewers disagree with each other, the gate surfaces the disagreement explicit — not papered over. If they all agree, you approve quickly.

Three is not better than two. I tried three (added “design-review” between plan and pm) for a month. The middle gate added 8-12 minutes of reading time, and in practice approved 47/47 features unchanged. Removed.

Decision 2: parallelism is structural, not opportunistic

The pm step does not just decompose tasks. It explicitly tags tasks as [parallel] or [serial], schedules them into a DAG, and assigns each parallel task its own git worktree.

This matters because the temptation in an LLM pipeline is to serialize everything (“agents think too fast, let’s not race them”). That’s wrong. Modern agentic coding tools (Claude Code in particular) handle parallel worktrees cleanly. The bottleneck is human attention, not compute. Running 4 senior-devs in parallel for 38 minutes is the same total LLM cost as serial, but cuts wall-clock by 3-4×.

Reviewers are also parallel — 5 specialist reviewers run concurrently against the merged diff, each looking at a different aspect (QA, security, performance, archetype-specific compliance, code quality across 12 angles). They do not block each other.

The only serial steps are architect (must precede pm) and devops (must follow approval).

Decision 3: memory is per-project, per-org, and cross-project

The dashed loop in the diagram is the part most agentic coding tools skip. It is the part that compounds.

Four layers:

  1. Per-session. Conversation history. Disappears at session end. Cheap.
  2. Per-project (.great_cto/lessons.md). Decisions, rejected approaches, incident detection patterns. Survives session restarts. Git-trackable.
  3. Per-org (~/.great_cto/decisions.md). Patterns confirmed across ≥3 projects. Promoted from per-project after manual review. Used as Step 0 context for architect.
  4. Cross-project (incident patterns). Pattern hash + winning detection order, stored when a P0 resolves. Next time a similar incident shape hits in a different project, the agent’s Step 0 includes the prior detection order.

This last layer is where the MTTR -94% claim comes from. It is not the agent being smarter; it is the agent skipping hypothesis exploration time because someone already paid for that exploration.

I tried a vector-DB-backed memory layer for two weeks. Abandoned. The cognitive overhead of a “search before you write” step in every agent prompt was worse than just listing 3-5 recent lessons in a markdown file and trusting the LLM’s context window. Plain text + git history is the moat, not embeddings.

Decision 4: archetype-specific agents are opt-in

The 34 specialist agents in the pool are not all firing every time. For a typical fintech feature, only 7 run: architect, pm, 2× senior-dev, qa-engineer, security-officer (PCI focus), code-reviewer. The voice-AI reviewer does not load because the archetype is not voice.

This is more important than it sounds. Early versions ran “all reviewers always.” Cost ballooned because every reviewer wants context (the diff + ARCH.md + project README) and most ran for 0 useful output. Now: archetype detection at init picks the relevant 5-7, no extras.

The detection is signal-based: regex matches in package.json, README, infra/. False positives happen (the static-site-generator that got TCPA threat-modeled) but auto-attach is reversible — PROJECT.md lists the active packs and you can remove one with a line edit.

What the state machine does not do

  • It does not write product specs. You bring the prompt.
  • It does not negotiate with stakeholders. The architect will write the ARCH note, but the conversation with your VP of Eng about “should we even build this” is yours.
  • It does not catch all bugs. The qa-engineer writes tests against the spec it inferred from the architect’s ARCH note. If you mis-spec’d the feature at gate:plan, the tests pass and the bug ships. Two gates is not zero — it is two.
  • It does not learn from your specific code style without per-project lessons. First 3-5 features in a new repo, you will see suboptimal naming and inconsistent patterns. By feature 10, lessons.md has enough to lock the style.

Where this fits

This is the architecture I argue every agentic SDLC needs. Not this specific tool. If you are building your own, the shape that works is: explicit state, two gates, parallel specialists, memory loop. Anything looser breaks at scale.

The clickable version of this diagram, with every node linking to its agent’s source on GitHub, is at greatcto.systems/architecture.

About: I build GreatCTO — a multi-agent SDLC plugin for Claude Code. MIT, runs locally. Twitter: @avelikiy.

First real shipped feature with this stack — receipts

I get asked for receipts on every cost number I publish. So here is one full run, end to end, with screenshots replaced by file paths you can read on GitHub.

The feature is real. It shipped to a real fintech codebase in March 2026. The prompt, agent outputs, costs, and gate decisions are all recorded in tasks/W14-stripe-webhook-retry in that project (I am not linking the client repo, but I am willing to share artefacts on request).

The feature

“Add idempotent retry handling to our Stripe webhook receiver. If Stripe re-delivers an event (network blip, our 5xx response, manual replay), we should not double-process. PCI scope must stay SAQ-A.”

Two sentences. No requirements document. This is roughly the level of brief I get from the team’s PM most weeks.

What ran

$ npx great-cto init
…detected archetype: fintech (PCI-DSS)
…attached pack: api-platform-pack
…attached pack: pci-pack

Pipeline rolled forward unattended through:

  1. archetype-detector — 12 seconds. Scanned package.json, infra/, README. Flagged Stripe webhook handler, BNPL flow, three PII columns in users table.
  2. architect — 4 minutes. Wrote ARCH.md with: idempotency key strategy (deterministic from event.id), 24-hour retention window for processed-event log, exit criteria for “duplicate”, interaction with existing audit log.
  3. Gate: plan — I read the ARCH note for ~3 minutes, asked one clarifying question about the retention window (“why 24h, not the 7d that Stripe retries for”), agent updated to 7 days, I approved.
  4. pm decomposed into 4 tasks, scheduled 2 in parallel.
  5. senior-dev × 2 — 38 minutes, parallel git worktrees. Output: 6-file diff, +287 / −41 lines.
  6. qa-engineer — 11 minutes. Wrote 17 new tests, including a property-based test for replay ordering. Coverage on touched code: 94%.
  7. pci-reviewer (auto-attached by fintech archetype) — 8 minutes. Verified no card data hits new code path; idempotency log table excluded from CHD scope.
  8. api-platform-reviewer — 7 minutes. Checked webhook signature replay window (5 min skew, ok), idempotency key collision math, Sunset header (n/a, internal endpoint).
  9. security-officer — 4 minutes. Verified no new secret access patterns, audit trail covers retry path.
  10. code-reviewer (12-angle) — 6 minutes. Three minor refactor suggestions, all accepted.
  11. Gate: ship — I saw 5 reviewer cards, scrolled through 2, approved.
  12. devops opened the PR. Branch CI was already green. Merged.

The receipts

Total wall-clock: 1h 26m. That includes the ~7 minutes I spent reading two artefacts and approving two gates. The agents themselves did about 78 minutes of work, mostly in parallel.

Total LLM cost: $1.42. Breakdown by agent (rounded):

architect          $0.34
pm                 $0.04
senior-dev × 2     $0.62
qa-engineer        $0.18
pci-reviewer       $0.09
api-platform-rev   $0.07
security-officer   $0.05
code-reviewer      $0.03

senior-dev is the cost driver (it writes the code). Reviewers are cheap (they output verdicts, not code). The whole thing fit inside the free monthly credit on Anthropic for the team account.

What I would have done manually

About 4-5 hours of senior backend work for the code, plus 1-2 hours of PCI review (we have an internal expert) before merge. Call it $700-$900 in fully-loaded engineering time. So the cost ratio is roughly 500× cheaper, the time ratio roughly 4× faster, with the same level of review rigour.

That is not “AI replaces the engineer.” It is “AI does the mechanical 80% so the engineer spends 7 minutes on the part where judgment matters.” The clarifying question I asked at gate:plan (retention window) is the kind of thing the agent would not have caught on its own.

⚠ Honest caveats

  • This was a small, well-scoped feature with clear PCI implications already known by the team. For greenfield features in unfamiliar regulatory territory, expect 2-3× longer wall-clock and a chunkier ARCH approval cycle.
  • The 78-min agent runtime ran on Claude Sonnet 4.6. On Haiku it would be ~30% faster and ~3× cheaper, with measurably weaker architect output (we tried).
  • One reviewer agent (the data-platform-reviewer) was opted out because we do not warehouse webhook events. If you do, that adds ~10 minutes and ~$0.10.
  • The team’s existing test suite was already in good shape. On a codebase with poor test infra, qa-engineer would either spend longer building scaffolding or punt — and you would notice at gate:ship.

What this proves and what it doesn’t

It proves the pipeline works on a feature you would have hand-built. It does not prove the pipeline can run your whole engineering org. We have not tried that and I do not recommend it. Two human gates per feature is the upper bound for “responsible automation”; more gates means slower, fewer means broken prod (see my earlier post on missing gates for why two is the right number).

If you want the same level of detail for a non-fintech archetype — say a voice-AI MVP or a clinical decision support tool — DM me on Twitter and I will publish another one.

About: I build GreatCTO — a multi-agent SDLC plugin for Claude Code. MIT, runs locally. Pay your own LLM API. The full architecture diagram, with every node mapped to its source on GitHub, is here.

Why I Built an AI Korean Astrology SaaS as a Solo Founder

Notes on shipping a four-language fortune-telling product without a co-founder, an investor deck, or a permission slip.

There is a particular kind of loneliness in building software alone. You wake up, look at the dashboard, see the number, and decide whether the number means today is real or not.

For me the number is users of a Korean astrology SaaS that I built and run by myself, in four languages, with payments wired through PayPal and Korean PG, with a recommendation engine that turns a person’s birth time into a year-long narrative — and with no team to share the wins or the silences with.

This is the story of how I got here, what I wish I had known eighteen months ago, and why I think building an AI-native product around a thousand-year-old tradition is not a contradiction but an opportunity that almost no one is taking seriously enough.

The wedge: a tradition that already has demand

Astrology as a global category is not a fringe market. The most recent third-party research I trust pegs the astrology app sector at roughly $5.7B in 2026, on a path to $11.7B by 2030 at a compound rate near 20% per year. That is not a niche. That is a category bigger than most “AI for X” pitches you see on a typical demo day.

Inside that category, the West has built a confident generation of horoscope products powered by sun signs, NASA ephemeris data, and a moody UI voice. What it has not built — and what has been almost entirely overlooked — is a serious productization of Eastern astrological systems: the Korean and Chinese tradition of saju (the four pillars of destiny), the Vedic chart, the ten heavenly stems and twelve earthly branches, the elemental compatibilities that govern marriage, business partnerships, and timing in cultures that still take these calculations seriously when buying a house.

I am Korean. I grew up watching my parents pay a human master a hundred thousand won to read someone’s chart by hand. The math is deterministic. The interpretation is not. That gap — deterministic input, narrative output — is exactly the shape of work that a modern language model is good at.

That was the wedge.

What I built

The product is a Korean astrology and life-pattern SaaS. The technical spine looks something like this:

  • A birth-time engine that converts solar time, lunar calendar, time zone, and true-solar-time correction into the canonical four-pillar representation. This is the part no Western astrology app ships, and it is non-trivial because lunar calendar tables have edge cases that bite.
  • A generation layer that feeds the pillar representation into a language model with a tightly bounded prompt scaffold. The model never gets to invent the chart. It only narrates the chart it is given.
  • A four-language front end. Korean and English are the obvious two. Japanese and Chinese were added next because the underlying tradition is shared and the cultural translation cost is much lower than re-inventing the whole interpretation tree for, say, German.
  • A payments layer that supports both global subscriptions and a Korean PG so that someone in Seoul can pay with their everyday wallet and someone in Los Angeles can use a global card.
  • A recommendation engine that compares a chart against curated themes (career year, relationship year, health year) and produces a personalized PDF in the user’s language.

It runs. It accepts money. The hardest part was not the model. It was the calendar.

What it cost in time

Eighteen months, mostly evenings and weekends at the start, full attention for the last six. Around two thousand commits across the monorepo. Two complete rewrites of the interpretation layer when I realized the first version was hallucinating in a way I could not bound. One pivot of the brand voice. Three pricing experiments. Many quiet weekends.

What it did not cost was a team. I have never had a co-founder. I have never had an employee. The closest thing I have to colleagues are the AI executives I run internally — but that is a separate essay and I will not collapse the two threads here.

What I wish I had known

A few things, in the order they would have helped me most:

One. The hardest single decision in this kind of product is not “which LLM” — it is what the model is allowed to invent versus what it must repeat verbatim. For a system rooted in tradition, “must repeat verbatim” needs to win more often than your intuition will tell you. Users do not want a model improvising on the meaning of the year of the Red Horse. They want the canonical interpretation rendered in their language, in their tone, for their chart. Inventiveness is a bug here, not a feature.

Two. Localization is not translation. The Japanese reader expects the chart to be presented through the lens of jūnishi and jikkan, with vertical-friendly text rhythm. The English reader expects a narrative arc that builds toward an actionable summary. The Chinese reader expects denser information density per screen. None of this is captured by a translation API. All of it has to be designed.

Three. A solo founder needs two flywheels, not one. The first is the product. The second is the infrastructure for shipping the product without supervision: scheduled tasks, monitoring, alerting, retry queues, and a daily morning brief that tells you whether the night-shift code did its job. Without the second flywheel, the first one stops every time you take a weekend off, and a year later you have shipped six months of work.

Four. The honest pricing answer for an Eastern astrology SaaS is not “match Western pricing.” Western horoscope apps anchor at three to ten dollars a month. Eastern astrology has a history of one-time, high-value readings — the in-person master charge is typically five to fifteen times higher than a monthly horoscope subscription. There is room here for an annual plan, a family plan, and a premium consultation tier that Western competitors have not even considered.

Five. Do not chase paid acquisition before the product can hold the user past the first chart. The category is generous to organic. Pinterest, TikTok, long-form blog content, Bluesky, Hashnode, and Mastodon will quietly feed you traffic that converts better than any cold ad — if the first reading is good enough to talk about at dinner.

On being alone

People ask me whether building a SaaS alone is harder than building one on a team. The honest answer is: it is differently hard.

A team gives you debate, redundancy, and a calendar full of other people’s energy. A solo build gives you decision speed, full context on every line, and zero meetings. The trade is real. I have shipped features in twenty minutes that would have been a two-week sprint at a startup with a stand-up culture. I have also gone three days without speaking to a human about the work, and that part is corrosive if you do not engineer around it.

What works for me is to treat the company as if it were a real company, even though it is not. Department names. Daily reports. Weekly reviews. Internal letters between fictional teams. A small fleet of AI executives that hold the work I would have otherwise dropped on the floor.

That second flywheel — the operating system for a one-person company — is what makes the first flywheel survive.

What is next

I am not yet at the scale where this product is generationally important. I am at the scale where it is real, where strangers pay for it, where it speaks four languages, and where the question is whether I can ten-x it without breaking the trust of the people who already paid.

The category is large. The Eastern astrology corner of it is wide open. The technology is finally good enough to render a tradition without flattening it. And the cost of one person building this in 2026 is genuinely the lowest it has ever been.

If you are sitting on a domain that has demand, a tradition, and a math that a model can carry — and you have been waiting for a co-founder before starting — consider that the co-founder you have been waiting for is the second flywheel.

Build it. Then build the first.

This is the first in a three-part series on building an AI-native Korean astrology SaaS as a one-person company. Next: how four AI executives run the operations of a company with zero human employees.

File-Based Agent Orchestration for Antigravity

I’m not a programmer, but I actively use AI in my areas of interest. And since my interests are heavily text-based, Antigravity turned out to be a very convenient environment for using agents somewhat beyond its intended purpose.

But imagine my disappointment when I realized I had no way to natively run a simple chain of agents that sequentially process a single document, each solving its own task. Each iteration required manual execution.

This was so strange and inexplicable to me that I began trying to find a solution for automating agents in Antigravity. No matter how much I searched or how much I called on the agents themselves for help, everything pointed to the same answer: use an API or a heavyweight framework.

I wasn’t satisfied with this situation because I needed something very basic: for the agents to pass the task down the chain without my involvement. All the prompts and skills were already ready, and I needed only one thing—not to do this manually in Antigravity.

In yet another desperate attempt to find a way to automate this, I managed to corner the agent. And we discovered a very simple, yet not at all obvious, solution.

I simply didn’t know that’s not how it’s done.

Without having written a single line of code in my life, I used agents to create an extension for Antigravity that allows agents to launch other agents. I was so impressed with the result that, forgetting everything else, I spent over two months enthusiastically developing it to share with the community.

The irony is that I’m critical of vibe-coded products—and yet I’ve become that very vibe-coder who’s bringing his creation into the world.

During this time, I learned a lot about how agents function in the Antigravity environment and developed my own approach to orchestrating reliable workflows based on this plugin, which can run for hours and involve hundreds of agents without any runtime or external control.

And all this thanks to just ~240 lines of code, with no external dependencies, two commands, and a single trigger file. But these constraints also shape the approach to building such workflows—which perhaps deserves a separate discussion.

A one-minute timelapse of a two-hour workflow demonstrating basic extension capabilities—not about OCR, but about the mechanics of Pipeline Relay using a clear example. Under the hood, there’s a simple prompt of a few dozen lines, and nothing else. An agent performs its part of the task and launches the next one.

And so on, 181 times over the course of 2 hours and 15 minutes. This isn’t a pure chain; parallel agents were launched to extract the illustrations. Each agent processes only one file or performs only one task—this is the key to stability and quality results in this architecture.

This approach is unlikely to work in every situation; it has many limitations, but, in my opinion, it also offers a number of unique capabilities. And perhaps you will find it useful too.

The duration, sequence, and completion of the orchestration are all defined through natural language prompts. But this simplicity gives rise to new complexities that require a special approach to organizing the work environment. I believe this could serve as a kind of testing ground for understanding how to manage agents.

I’ve also tried Claude Code and Codex, which have native orchestration. But for me, it’s far clearer, more convenient, and more interesting to use this particular extension. What I accomplish here with a single prompt is significantly harder to do there.

  • What I like about Antigravity is that everything happens in chats; I see the full picture of what’s going on and can evaluate agent behavior in real time, rather than based on results.
  • This also provides a clear structure for the sequence of events.
  • There are no limits on workflow duration or the number of agents involved, except for objective constraints (tokens, connection stability, server availability).
  • There is no “black box”; every agent can be an orchestrator, and I decide how the orchestration will proceed.
  • No code or complex abstractions are needed; you just need to think through the logic and clearly define the prompts.

This release is a minimalist core, but there is potential for further development around this core, and perhaps the community will explore it. I already have several ideas that I plan to implement in the future, if circumstances allow.

You can install the plugin directly from Antigravity — Pipeline Relay on Open VSX

For technical details — GitHub repository

I’d love to hear your feedback.

Creating Dynamic Lists in React: A Step-by-Step Guide

Creating Dynamic Lists in React: A Step-by-Step Guide

Lists in React are similar to lists in JavaScript. They are commonly used to display menus or a list of items on a website. To use lists in React, we need to use a unique key attribute for each item in the list. This is important because it helps React identify which items have been changed, updated or removed.

Hey there friends! It’s been a minute since I last posted on this platform, but I’m back and ready to dive into the world of React once again. Let’s get started!

To create a list in React, it is best to create a new component specifically for the list. This component will take in the list data through props. Inside the component, we can use the map function to iterate through the list and display each item.

For the sake of simplicity and learning, this article will give a simple example of creating a list of posts, where the key is “id” of the post, and the “title” and “body” of the post is displayed. The final output of the tutorial is shown in an embedded code editor. It’s recommended to follow this tutorial and practice it several times to better understand the concepts.

Code Output

You will get this result once you have finished the tutorial.

Edit list-and-keys

Getting Started

To begin, you can create react app using the command line or any code editor (e.g., VSCode). You can also directly use the CodeSandbox for an online code editor that is simple to use and allows you to deploy your code.

What Components Needed

First, we need to understand what components are required to display a list of blog posts using React. You will need to create at least the following components:

  1. A component that retrieves the blog post data from a source, a Parent component – (App.js).

  2. A component that renders the list of blog posts, a Child component – (BlogList.js).

  3. A reusable component that can be reused in other parts of the application where you want to display a single blog post, an Inner Child Component – (BlogCard.js).

You can create these components as separate JavaScript files and import them into your main application file, where you can use them to build out the list of blog posts.

Data Retrieving Component

  1. App.js – This component would handle retrieving the blog post data from any source. For the sake of simplicity, we will just have a simple array of objects [{key:value}, {key:value}] and that is the const posts. Inside them, we have the id, title, and body.

We will import the BlogList component into the App component and pass the posts data through props because we want to use the posts data to be displayed in the BlogList.

App.js

import BlogList from "./components/BlogList";

export default function App() {
    const posts = [
        {
            id: 1,
            title: "buildspace web3 demo showcase!",
            body:
                "Last night's buildspace demo day was a blast, as was go-karting around the map on gather.town dressed as a pumpkin. It's a massive web3 display! Over…"
        },
        {
            id: 2,
            title: "ReactJS - Introduction",
            body:
                "What is ReactJS? ➡️declarative, efficient, and flexible JavaScript library for building reusable UI components. ➡️an open-source, component-based…"
        },
        {
            id: 3,
            title: "Build Metaverse with Gather.Town for Free",
            body:
                "Gather.Town Looking for more interactive web-conferencing software like Zoom, but with more fun virtual interactions just like in real life. Then, you…"
        }
    ];

    return (
        <div>
            <BlogList posts={posts} />
        </div>
    );
}

Display the Lists Component

  1. BlogList.js – This component would render the list of blog posts using the data retrieved by the Appcomponent and display all the posts on our screen browser.

Inside the BlogList functional component, it will take props.

import BlogCard from "./BlogCard";

const BlogList = (props) => {
    const content = props.posts.map((post) => {
        return <BlogCard key={post.id} props={post} />;
    });

    return (
        <div>
            <h1>My Posts</h1>
            <div>{content}</div>
        </div>
    );
};

export default BlogList;

This code is a React component named BlogList which maps over an array of blog posts (props.posts) and returns an array of BlogCard components. Each BlogCard component is rendered with a unique key prop (post.id) and passed the whole post data as props (props={post}).

Let’s go line-by-line through the code:

  1. import BlogCard from "./BlogCard": This line imports a component called BlogCard from another file called BlogCard.js in the same directory.

  2. const BlogList = (props) => {...}: This line creates a new component called BlogList using a special type of function called an arrow function. This component will receive data from outside the component through its props argument.

  3. const content = props.posts.map((post) => {: This line creates a new constant called content which will contain an array of components created by mapping over the posts array received through props. It maps through an array of posts stored in props and creates a new array of BlogCard components for each post where it will return a component for each post. “Mapping through an array” means iterating/looping over the elements of an array and performing a specific operation on each element.

  4. return <BlogCard key={post.id} props={post} />;: This line returns a new component of type BlogCard for each post in the posts array. The component will receive data through its props argument and will have a key property set to the id of the current post. Remember to use lists in React, we need to have a unique key attribute for each item in the list. Otherwise, a warning will appear.

  5. <div>{content}</div>: This line returns a div element that contains all the BlogCard components created in the previous step. Which embeds the mapped array of BlogCard components into a div element.

  6. <h1>My Posts</h1>: This line displays a header element with the text “My Posts”.

  7. <div>...</div>: This line wraps the header and the content div in a parent div.

  8. export default BlogList;: This line exports the BlogList component so that it can be used in other files.

The overall logic of this code is to create a component BlogList that displays a list of blog posts using the BlogCard component. The component receives data about the posts through its props argument, maps over the posts array to create an array of BlogCard components, and displays these components in a div along with a header.

Reusable Component

  1. BlogCard.js – This component would be a reusable component that takes a single blog post as a prop and displays the details of the blog post in a card format. This component can be reused in other parts of the application where you want to display a single blog post.

This component uses destructuring assignment to extract the “id”, “title”, and “body” properties from the props object.

This component in React is called “BlogCard”. It takes in a prop called “props”, which contains information about a blog post. The component then uses destructuring assignment to extract the “id”, “title”, and “body” properties from the props object.

The component then returns a JSX element that displays the post information in a specific format. The id of the post is displayed first, followed by its title, and then its body content.

const BlogCard = ({ props }) => {
    const { id, title, body } = props;

    return (
        <div>
            <h2>
                {id}. {title}
            </h2>
            <p>{body}</p>
        </div>
    );
};

export default BlogCard;

Let’s go through line-by-line:

  1. const BlogCard = ({ props }) => {...}: This line creates a new component called BlogCard using an arrow function. This component will receive data from outside the component through its props argument.

  2. const { id, title, body } = props;: This line uses destructuring to extract the id, title, and body properties from the props object.

  3. <h2>{id}. {title}</h2>: This line returns a header element with the id and title properties from the props object.

  4. <p>{body}</p>: This line returns a paragraph element with the body property from the props object.

  5. <div>...</div>: This line wraps the header and paragraph elements in a parent div.

  6. export default BlogCard;: This line exports the BlogCard component so it can be used in other files.

The overall logic of this code is to create a component BlogCard that displays a single blog post. The component receives data about the post through its props argument and displays the post’s id, title, and body properties in a header and a paragraph element.

Destructuring

Destructuring is a feature in JavaScript that allows you to extract data from arrays or objects and assign them to variables. The main purpose of destructuring is to make it easier and more convenient to access the properties and values of an object or array.

// First code snippet
const BlogList = (props) => {

// vs

// Second code snippet
const BlogCard = ({ props }) => {

What is the difference between these two? Why are props inside the BlogList not destructured?

The difference between these two code snippets is that in the first code snippet, props is passed to the BlogList component as a single argument, and in the second code snippet, props is destructured from the props object passed to the BlogCard component.

  1. First code snippet – props is an object that contains all of the properties that are passed to the BlogList component. In this case, it is not necessary to destruct the props object, as the code only needs to access one property, posts.

  2. Second code snippet – props is destructured from the props object, allowing for the properties id, title, and body to be accessed directly. This reduces the need to access properties through the props object and makes the code more readable and maintainable.

In short, the difference is in the way that the properties are accessed within the component. The first code snippet accesses the properties through the props object, while the second code snippet accesses the properties directly by destructuring the props object.

Full Code

App.js

import BlogList from "./components/BlogList";

export default function App() {
    const posts = [
        {
            id: 1,
            title: "buildspace web3 demo showcase!",
            body:
                "Last night's buildspace demo day was a blast, as was go-karting around the map on gather.town dressed as a pumpkin. It's a massive web3 display! Over…"
        },
        {
            id: 2,
            title: "ReactJS - Introduction",
            body:
                "What is ReactJS? ➡️declarative, efficient, and flexible JavaScript library for building reusable UI components. ➡️an open-source, component-based…"
        },
        {
            id: 3,
            title: "Build Metaverse with Gather.Town for Free",
            body:
                "Gather.Town Looking for more interactive web-conferencing software like Zoom, but with more fun virtual interactions just like in real life. Then, you…"
        }
    ];

    return (
        <div>
            <BlogList posts={posts} />
        </div>
    );
}

BlogList.js

import BlogCard from "./BlogCard";

const BlogList = (props) => {
    const content = props.posts.map((post) => {
        return <BlogCard key={post.id} props={post} />;
    });

    return (
        <div>
            <h1>My Posts</h1>
            <div>{content}</div>
        </div>
    );
};

export default BlogList;

BlogCard.js

const BlogCard = ({ props }) => {
    const { id, title, body } = props;

    return (
        <div>
            <h2>
                {id}. {title}
            </h2>
            <p>{body}</p>
        </div>
    );
};

export default BlogCard;

👉 Please share my posts with the community at daily.dev / social media by adding the article’s URL to the feed. By adding my article’s URL to the feed, I can share my insights and knowledge with other tech enthusiasts and contribute to the passionate community.

Cheers✨

Thank you for your continued support and for sticking around even though I’ve been away for a bit. I’ve been busy with some exciting projects, but I’m thrilled to be back and writing again!