<![CDATA[Lunatech Blog]]> https://blog.lunatech.com/ Quarkus Roq Wed, 10 Jun 2026 00:00:00 +0000 <![CDATA[The software-defined vehicle moves the work to the backend]]> https://blog.lunatech.com/posts/2026-06-10-software-defined-vehicle-backend/ https://blog.lunatech.com/posts/2026-06-10-software-defined-vehicle-backend/ Wed, 10 Jun 2026 00:00:00 +0000

A modern car is a computer that happens to have wheels. The interesting code no longer lives in the engine bay. It lives on a server, talking to the vehicle over the network, deciding what the fleet knows and what each car runs next. The industry calls this the software-defined vehicle. The name hides where the real engineering goes: into the backend.

We built a working demo to make that point concrete. It manages a fleet of twenty vehicles. Live GPS positions flow from each car to a map in the browser. Software updates roll out over the air from a panel in the UI. The whole thing starts with one docker compose up and runs on a laptop.

It is open source. You can read the code . This post walks the data path end to end, then explains why the server side is written in Rust.

The shape of the system

The architecture is a pipeline. Vehicle state originates in per-vehicle Eclipse Kuksa Databrokers, crosses an Eclipse Mosquitto MQTT broker, lands in a Rust backend, and reaches the browser over a WebSocket. Over-the-air campaigns run on a parallel track through Eclipse HawkBit down to per-vehicle update agents.

SDV Fleet Management architecture: the data path

Three Eclipse SDV projects do the heavy lifting, and we adopted them rather than inventing private equivalents. Kuksa holds vehicle state. HawkBit runs the update campaigns. Mosquitto moves the messages. Building on an open ecosystem in the open is itself a form of contribution, and it is the honest first step toward upstream work.

Stage one: Kuksa and the Vehicle Signal Specification

Each of the twenty cars runs its own Kuksa Databroker. A Databroker is a small gRPC service that holds the current value of a vehicle's signals, addressed by the Vehicle Signal Specification (VSS). VSS is a tree. A position is not a bespoke field, it is Vehicle.CurrentLocation.Latitude and Vehicle.CurrentLocation.Longitude. Identity is Vehicle.VehicleIdentification.VIN, .Brand, .Model.

The contract is gRPC, so the signals are typed and discoverable. You can read a signal straight from a broker with grpcurl:

grpcurl -plaintext \
  -d '{"entries":[{"path":"Vehicle.CurrentLocation.Latitude","fields":["FIELD_VALUE"]}]}' \
  localhost:55556 kuksa.val.v1.VAL/Get

Twenty brokers means twenty independent sources of truth, one per VIN, each listening on its own port. That mirrors reality: a fleet is not one database, it is many vehicles that each know their own state.

Stage two: the sidecar that bridges gRPC to MQTT

Kuksa speaks gRPC. The rest of the pipeline speaks MQTT. Something has to bridge the two, and that is the kuksa2mqtt sidecar, written in Rust, one instance per vehicle.

The sidecar is the source of each car's movement. It runs a GPS random walk, writes the new latitude and longitude into the Databroker over gRPC once per second, and publishes the same pair to MQTT on a per-vehicle topic. Identity is different. VIN, brand, and model do not change, so the sidecar publishes them once at startup. The topic structure carries the VIN and the VSS path, so a subscriber can filter exactly what it wants:

kuksa/VIN-0001/telemetry/CurrentLocation/Latitude    48.8571
kuksa/VIN-0001/telemetry/CurrentLocation/Longitude   2.3529
kuksa/VIN-0001/telemetry/VehicleIdentification/Brand Toyota

This is a deliberate seam. The backend never talks gRPC to the cars. It subscribes to one wildcard topic and lets the broker fan in the whole fleet. Adding a vehicle means starting another Databroker and another sidecar. The backend code does not change.

Stage three: the axum backend

The backend is an axum service on port 3000. On startup it subscribes to a single wildcard:

kuksa/+/telemetry/#

The + matches any VIN, the # matches any signal path below it. One subscription, the entire fleet. As messages arrive the backend updates an in-memory view of fleet state, keyed by VIN. There is no database in the hot path. The current position of a car is a value in a map, protected for concurrent access, updated as fast as MQTT delivers.

The REST surface is thin and predictable:

GET  /fleet                 snapshot of all vehicles
GET  /vehicles/{vin}        one vehicle
GET  /versions              available software versions
GET  /campaigns             campaign list and state
POST /campaigns             launch a rollout

The handler signatures read the way axum encourages: typed extractors in, typed responses out. Launching a campaign takes a body with a target version and a list of VINs, and returns the created campaign or a typed error.

#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateCampaign {
    pub version: String,
    pub vins: Vec<String>,
}

pub async fn create_campaign(
    State(state): State<AppState>,
    Json(req): Json<CreateCampaign>,
) -> Result<Json<Campaign>, (StatusCode, Json<ApiError>)> {
    // start the HawkBit rollout, fold it into campaign state, return the record
}

The ToSchema derive is not decoration. It is what feeds the OpenAPI generator, and we will come back to it.

The browser does not poll for live data. It asks once over REST for the initial snapshot, then opens a WebSocket and listens. The frames are small Rust types serialised to JSON. The position event is exactly three fields:

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PositionEvent {
    pub vin: String,
    pub lat: f64,
    pub lon: f64,
}

On the wire the two streams look like this:

// /ws/fleet
{"vin":"VIN-0003","lat":48.8641,"lon":2.3318}

// /ws/campaigns
{"campaign_id":"...","vin":"VIN-0001","state":"Installing"}

The map moves because the server tells it to, not because it keeps asking. This is the single most important property of the read path: state changes are pushed, never polled, and the wire format is small enough to be obvious at a glance.

The OTA track: HawkBit and a state machine per vehicle

Updates run on their own path. When you launch a campaign from the UI, the backend asks HawkBit to start a rollout for the chosen version and target VINs. HawkBit owns the Direct Device Integration (DDI) protocol: each OTA agent, one per vehicle and written in Rust, polls HawkBit for assigned actions, downloads, installs, and reports progress.

Every vehicle moves through its own state machine:

Pending -> Downloading -> Installing -> Complete
                                     -> Failed

The agent reports each transition to HawkBit over DDI. The backend polls HawkBit for progress, folds it into campaign state, and pushes it to the UI over the WebSocket, where the map marker changes colour. Two tracks reach the same screen: telemetry arrives over MQTT, campaign state comes from HawkBit. Neither is polled by the browser.

One detail we kept on purpose: every OTA agent fails its update twenty percent of the time, deliberately. A demo that always succeeds teaches nothing about the system you would actually run. Real fleets have cars with bad connections and interrupted downloads. The interesting code is the code that handles the unhappy path. So some markers turn red, the agent reports the failure to HawkBit, and the state machine records it instead of pretending the rollout went clean. A rollout you can trust is one that tells you when it breaks.

Why Rust on the server

The data path from car to screen is Rust from end to end: the sidecars, the backend, the update agents. That is not an accident. The reasons are concrete.

Concurrency without surprises. The backend holds shared fleet state while MQTT messages, REST requests, and WebSocket pushes all touch it at once. Rust's ownership model turns the data races you would chase at runtime in another stack into compile errors. The borrow checker is doing fleet-state integrity for free.

Typed integration boundaries. Every seam in this system is a place where a wrong assumption costs you. A VSS path, an MQTT payload, a HawkBit response, a JSON frame on the wire. Modelling each as a Rust type means a malformed message fails at the edge, with a clear error, instead of propagating a bad value toward a vehicle.

A small, predictable footprint. axum on Tokio gives an async runtime with no garbage collector pauses and a memory profile flat enough to run twenty sidecars, twenty agents, and a backend on a laptop without thinking about it.

The API contract cannot drift. The OpenAPI specification is generated directly from the Rust source with utoipa: the same #[derive(ToSchema)] on a type and the #[utoipa::path(...)] attribute on a handler that the compiler checks are what produce the spec. The Swagger explorer at /docs is never out of date with the running server, because there is no second source of truth to fall out of sync. The types that define the handlers are the types that define the spec.

open http://localhost:3000/docs   # generated from the Rust source

How it was built

We built this with Claude as a coding partner. The repository carries its working context in a file at the root, so the assistant understood the architecture, the conventions, and the constraints on every session. The result is not a prototype held together by hand. The Rust side runs cargo fmt, cargo clippy -- -D warnings, and cargo test on every push. The frontend lints and unit-tests. A Playwright end-to-end suite drives the full stack. All of it runs in continuous integration.

That is the part worth noting for anyone weighing AI-assisted development. The leverage is real, but it shows up as discipline, not shortcuts. The tests, the generated API contract, and the clean separation between services are what let a small team move fast without leaving a mess behind.

See it run

Twenty vehicle pins are spread across Paris, each moving on its own at one update per second. Green pins are up to date, red pins are out of date or have a campaign in progress.

Live fleet map with twenty vehicles moving across Paris

A full walkthrough: the live map, the vehicle drawer, the fleet table, an over-the-air campaign launch, and the state updates landing in real time.

Run it yourself

git clone git@github.com:lunatech-labs/sdv-fleet-management.git
cd sdv-fleet-management
cp .env.example .env   # set HAWKBIT_TOKEN, HAWKBIT_USER, HAWKBIT_PASSWORD
docker compose up

Open the map at http://localhost:8080 . Twenty cars move across Paris at one update per second. Click one to read its VIN, brand, model, and software version, all served from the single /fleet snapshot. Launch a campaign and watch the markers change colour as each vehicle walks its own state machine. Open a second terminal and websocat ws://localhost:3000/ws/fleet to read the raw frames the browser is consuming.

The software-defined vehicle is a backend problem wearing a car. The Eclipse SDV projects give the industry a shared foundation to build on. We are building on it, in the open, and we would rather show the work than describe it.

]]>
<![CDATA[LunaBet: shipping a Panini-style World Cup pool in Rust, in an afternoon]]> https://blog.lunatech.com/posts/2026-05-30-lunabet-a-panini-style-world-cup-pool-in-rust/ https://blog.lunatech.com/posts/2026-05-30-lunabet-a-panini-style-world-cup-pool-in-rust/ Sat, 30 May 2026 00:00:00 +0000

Every four years the same conversation happens in our Lunatech , and the Lunatech-internal instance lives at lunatech.lunabet.eu .

LunaBet landing page

The story in one sentence

LunaBet is a small multi-tenant betting app for the FIFA World Cup 2026. Pick the exact score of every match, score points (3 for the exact score, 1 for the right winner), climb the leaderboard, optionally chip in 2, 5 or 10 euros to a shared pot that the top 3 split at the end of the group stage. There is one rerun, with the obligatory "the ball is your friend" quote from Olivier Atton.

Multi-tenant by design

The interesting bit is that LunaBet is not a single-tenant Lunatech tool. Any organization can sign up at lunabet.eu , pick a slug, and immediately get their own branded space at <slug>.lunabet.eu. The only restriction is that your colleagues have to sign in with an email that matches your organization's domain, which keeps the pool internal without any user management on your side. Lunatech itself is just one of many possible tenants: lunatech.lunabet.eu is for people with an @lunatech.com email, but a friend in another shop can spin up acme.lunabet.eu for @acme.com in less than a minute.

Tour of the app

The login flow is a magic link, so there are no passwords to remember and no SSO to configure. Type your work email, click the link, and you are in.

Magic link sign-in

The matches page is the place where you spend most of your time during the tournament. Each match is a Panini-style sticker card with the two flags, the kick-off time, and a tiny inline form to predict the score. Bets close at kick-off, so no insider trading once the players are on the pitch.

Matches page with the Panini stickers

The leaderboard updates automatically as soon as a match is final. The 3D goal-shot scene at the top is rendered in the browser with Three.js, full goal cage with posts, crossbar and a translucent net, a parabolic top-corner shot, a flash and a bounce, the whole thing looping every few seconds. It is gratuitous and we love it.

Leaderboard with the 3D goal scene

If you want skin in the game, you can join the pot for 2, 5 or 10 euros. The app never touches the money: you Lydia or bank-transfer the admin, the admin marks you as paid, and the pot is shared between the top three paid players at the end of the group stage. Higher stakes get a bigger slice of their position's share, which means a 10 euro player in third still walks away with something interesting.

Choosing a stake tier

Admins get a small back office to mark payments as received and to settle the pot.

Admin stakes management

Everything is bilingual, French and English, switchable from the topbar. The leaderboard in English looks like this:

English leaderboard

The transactional emails follow the same theme: navy header, a striped pitch, a red stamp button. Magic-link emails are localized to the visitor's language at request time, while match reminders are bilingual side by side because we do not store a per-user language.

 French version
 English version
Bilingual match reminder

The stack, and why

The codebase is intentionally boring. The whole app is one Rust binary built with Axum, with templates compiled into the binary via Askama, htmx for the tiny bits of client interactivity, Three.js loaded from a CDN for the 3D ball, and lettre for the SMTP side of magic links and reminders. Persistence is PostgreSQL through SQLx, with migrations applied at startup so deployment is just drop the binary and restart. Match fixtures and results are pulled from football-data.org every five minutes, which is also when the reminder job fires.

That last point is more interesting than it sounds: football-data.org exposes hundreds of competitions behind a single competition code, so spinning up a pool for a new tournament is essentially a one-line change. FOOTBALL_DATA_COMPETITION=WC gives you the World Cup, EC gives you the Euros, CL the Champions League, PL the Premier League, and so on. Restart the binary and the fixtures, results and reminders for the next competition flow in on their own. The Panini sticker theme and the scoring rules do not care which tournament is in season, so the same instance can be repurposed for whatever your team wants to bet on next.

Rust was not a bet, it was the path of least resistance. A single statically linked binary, no runtime to install on the server, no garbage collection pauses to worry about, and the type system catches the kind of off-by-one mistakes that ruin scoring logic when you are coding at 11 pm. Axum and SQLx are mature enough that you spend zero time fighting the framework and all your time on the actual problem, which in our case was "make exact-score betting fun".

How fast can you actually ship?

LunaBet is also, quietly, an example of the kind of "orchestrating, not coding" story I wrote about a few weeks ago . The first usable version, with magic links, scoring, leaderboard, fixtures sync and emails, came together in a single afternoon and an evening. Not because the AI did it, and not because I am particularly fast at Rust, but because the context was right: a clean problem statement, a target stack chosen up front, a Panini moodboard pinned to the brief, and a tight feedback loop between writing and testing. The model wrote a lot of the scaffolding. I wrote the rules, the constraints, and the look. Then I read every line.

The multi-tenant work, the per-org branding, the bilingual emails and the dev-mode one-click sign-in came in a second pass once the core was solid. Same recipe: small, well-scoped tasks; verify each output; commit when green.

Try it

If your team has been threatening to redo The Spreadsheet, just send them to lunabet.eu . Pick a slug, confirm by email, and your office pool is live before the kick-off of the opener. The code is open source under Apache 2.0 at github.com/lunatech-labs/lunatech-lunabet if you would rather self-host or fork it.

One legal note before you do: a real-money pool between colleagues, even when settled outside the app on the honor system, can fall under your local gambling regulation. In France it is the ANJ. Run the idea past HR or legal before any public rollout. LunaBet never touches the money, which limits exposure, but it does not eliminate it.

'''

]]>
<![CDATA[Legacy Modernization with AI: A Battle-Tested Methodology]]> https://blog.lunatech.com/posts/2026-05-26-legacy-modernization-with-ai/ https://blog.lunatech.com/posts/2026-05-26-legacy-modernization-with-ai/ Tue, 26 May 2026 00:00:00 +0000

In the previous articles, we established three things: inaction is expensive, AI is collapsing the cost of building software, and engineers are becoming orchestrators rather than instrumentalists. The natural question is: how do you put all of this together into a real engagement, with real stakes, real data, and zero tolerance for regression?

This article reveals the methodology we have been building and refining at Lunatech over the past two years: Legacy Modernization with AI.

The operating principle

Everything in our framework starts from a single, non-negotiable principle:

For the same input, under the same preconditions, the new system produces exactly the same observable output as the legacy system.

100% functional parity. Verified, not assumed. This is not a best-effort goal -- it is the exit criterion. Every phase, every deliverable, and every quality gate is designed to serve this principle.

Three commitments

We make three commitments on every engagement.

First, 100% functional parity. We use behavioral equivalence testing and parallel running to prove that the new system matches the old one. Parity is verified through automated comparison, not through manual testing or optimistic assumptions.

Second, a reproducible methodology. The same seven-phase framework, the same deliverables, the same quality gates -- on every engagement, regardless of size. Consistency removes guesswork and makes outcomes predictable.

Third, AI as a first-class tool. We use agentic coding with Claude Code on every engagement, supported by a dedicated AI engineer who curates context for every task. AI is not an afterthought or an experiment. It is central to how we deliver.

The seven phases

Our framework follows seven phases, each with clear deliverables and exit gates.

The 7-Phase Legacy Modernization Methodology

Phase 1: Discover (1-2 weeks)

Stakeholder interviews, system mapping, and the creation of the first version of the context pack. The goal is to understand what the system does, who depends on it, and what "done" looks like for this engagement.

Phase 2: Reverse-Engineer (1-3 weeks)

Claude Code scans the legacy codebase, extracts business rules, and maps dependencies. The AI engineer captures golden datasets -- curated input/output pairs that define the system's expected behavior. This phase produces the living documentation the legacy system never had.

Phase 3: Re-Architect (1-2 weeks)

The target stack is defined, architecture decision records (ADRs) are written, and the migration strategy is formalized. The delivery plan is priced. Clients can stop at this phase boundary with a complete modernization blueprint in hand.

Phase 4: Rebuild (4-10 weeks)

This is where agentic coding does the heavy lifting. Modules are translated one by one using Claude Code, with each translation verified against the golden dataset. Equivalence tests run in CI on every commit. The context pack grows with every module completed.

Phase 5: Migrate (2-4 weeks)

Data is classified into four categories -- reference data, master data, transactional data, and in-flight data -- each with its own migration path, validation criteria, and rollback plan. Parallel running begins: production inputs are routed to both the legacy and target systems, and a reconciliation engine compares outputs field by field, row by row.

Phase 6: Harden (1-3 weeks)

Penetration testing, load testing, observability wiring, and compliance evidence gathering. Everything that makes a system production-ready beyond functional correctness.

Phase 7: Handover (1-2 weeks)

The context pack is transferred to the client's team. Training covers developers, operators, and business stakeholders. An AI-in-engineering enablement program teaches the client's team to use Claude Code effectively for continued evolution. A 30-day hypercare period is included.

NOTE

Scale bands: Small engagements (4-6 weeks), Mid-size (2.5-6 months), Large (4-8 months).

The Claude Context Pack

At the heart of the methodology is the Claude Context Pack -- a versioned folder in the project repository, treated with the same rigor as production code. It contains seven layers:

System
Plain-language description of what the system does.
Code
Modules, responsibilities, dependencies (AI-generated).
Data
Schemas, data dictionaries, golden I/O samples.
Business
Domain glossary, business rules in plain text.
Architecture
Target stack, coding standards, NFRs, security defaults.
Test
Equivalence test patterns, behaviors to preserve.
Decision
ADRs, open questions, non-negotiables.

A dedicated AI engineer builds, curates, and maintains the context pack throughout the engagement. Context review happens at every phase gate. The pack grows richer with each phase, and every AI task draws from it.

Importantly, Claude never gets the full pack at once. For each task, a task-specific brief is assembled from the relevant slices. Translating a module? The agent receives the legacy code, the business rules, the target architecture, the equivalence tests, and a completed example. Writing a migration script? It receives schemas, transformation rules, and golden datasets for verification.

This is the secret to consistent quality: targeted context produces precise output.

After handover, the context pack stays with the client as living documentation the legacy system never had, onboarding material for new developers, the foundation for continued AI use, and traceable architecture decisions. The system finally has the documentation it should have always had.

How parity is verified

Parity verification follows the Input/Output Equivalence Model. Production inputs are routed to both the legacy and target systems. A reconciliation engine compares outputs and classifies every variance into one of three categories:

  1. A parity defect blocks go-live and must be fixed.

  2. A known legacy defect corrected in the target is a signed-off exception where the new system is intentionally better.

  3. An acceptable minor difference is documented and approved.

Daily reconciliation reports track progress. Cut-over is rehearsed in staging with explicit rollback steps. Go-live happens only when the reconciliation report shows zero unresolved parity defects.

Why small teams win

Our team model is deliberately lean: 2-4 senior engineers, one dedicated AI engineer, and one project lead. This is not a cost-cutting measure -- it is a performance optimization.

The human bottleneck in software projects is not production. It is coordination, review, and judgment. Doubling headcount doubles overhead: more standups, more merge conflicts, more context-switching, slower decisions. Small teams of senior engineers working with Claude Code outperform larger teams on every metric that matters.

The AI engineer is a role unique to our methodology. This person curates context, maintains the context pack, and optimizes AI workflows daily. They are the guarantee that AI output quality stays high throughout the engagement.

Five metrics we track

We measure progress with five metrics, all auditable and reported weekly.

Metric Definition
Module Coverage % of legacy modules with a target equivalent passing equivalence tests
Context Pack Completeness % of the 7 layers populated and reviewed by the AI engineer
Golden Dataset Coverage % of identified scenarios with curated input/output pairs
Equivalence Test Pass Rate % of golden-dataset test cases passing on the target system
Reconciliation Variance % of parallel-run transactions with zero variance

Every metric is measurable, auditable, and reported weekly. There is no ambiguity about where the project stands at any point.

What you get at the end

When the engagement is complete, clients walk away with a modern, cloud-native system with 100% verified functional parity. They get predictable cost through per-phase pricing (fixed price or capped T&M) -- and they can stop at any phase boundary. They receive a future-ready stack, ready for continued evolution with AI-augmented workflows. And they keep the context pack as a permanent asset: the documentation, the knowledge, and the foundation for their team to keep evolving.

A modernization that leaves you unable to evolve has only solved half the problem. We solve both halves.

'''

This is the fifth and final article in the series "Software Will Cost Almost Nothing. What Happens Next?" If you are considering a legacy modernization project and want to explore what AI-accelerated delivery could look like for your organization, get in touch.
]]>
<![CDATA[From Coding to Orchestrating: The New Role of the Software Engineer]]> https://blog.lunatech.com/posts/2026-05-19-from-coding-to-orchestrating/ https://blog.lunatech.com/posts/2026-05-19-from-coding-to-orchestrating/ Tue, 19 May 2026 00:00:00 +0000

There is a widespread fantasy about AI in software development. It goes like this: you tell the AI to rewrite your entire banking system in Kotlin, press a button, go to lunch, and come back to a perfect application.

That is not how it works. And understanding why is the most important career insight for any engineer today.

It is not magic

Without structure, AI produces plausible but generic output that requires extensive rework. Ask it to "rewrite this COBOL module in Java" and you will get syntactically correct code with wrong field names, missing business rules, no error handling patterns, and a result that needs 80% rework. It looks impressive in a demo. It fails in production.

The difference between success and failure lies entirely in methodology. AI is extraordinarily powerful, but it is not autonomous in any meaningful sense. It does not know your domain, your constraints, your naming conventions, your business rules, or your definition of "done." It needs to be told. And the quality of what you tell it determines the quality of what you get back.

This is captured in a single equation that governs everything:

AI Output Quality = AI Capability x Context Quality

AI capability improves every quarter. You cannot control it. That is Anthropic's job, or OpenAI's, or Google's. Context quality is the lever you own. This is where engineering discipline makes the difference.

Conductors, not instrumentalists

The metaphor that best captures the new role of the software engineer is the orchestra conductor. A conductor does not play every instrument. They set the tempo, the dynamics, and the interpretation. They ensure that dozens of musicians play together coherently, that the quiet passage builds properly into the crescendo, that the timing is precise.

Similarly, the modern software engineer does not write every line of code. They orchestrate AI agents -- setting the context, defining the constraints, breaking work into coherent tasks, verifying each output, and accumulating knowledge over time.

The new core skills are: preparing the right context, defining quality gates, structuring work for AI consumption, verifying output rigorously, and curating knowledge so it compounds over time.

A tale of two prompts

The difference between naive and engineered AI use is dramatic.

The naive prompt says: "Rewrite this COBOL module in Java." What you get back is generic Java code with wrong field names, missing business rules, no error handling patterns, and a result that needs 80% rework.

The engineered prompt says: "Rewrite COBOL module X following target architecture Y, with these business rules, these naming conventions, these test cases, and these equivalence criteria." What you get back is code that is correct on the first pass, matches the target architecture, uses domain-specific naming, and passes equivalence tests. It needs 5-10% review.

The delta between these two outcomes is not the AI model. It is the context.

What context means in practice

At Lunatech, we have formalized context into seven layers that together form what we call the Claude Context Pack:

Context Layer
System
What the system does, who uses it, and why it exists.
Code
Structure, modules, and dependencies.
Data
Schemas, golden input/output samples, and data categories.
Business
Domain glossary, business rules, and workflows.
Architecture
Target stack, coding standards, and non-functional requirements.
Test
Equivalence test patterns and behaviors to preserve.
Decision
Architecture decision records (ADRs), constraints, and non-negotiables.

Each AI task receives a task-specific brief assembled from the relevant slices. Translating a module? The agent gets the legacy code, the business rules, the target architecture, the equivalence tests, and an example. Writing a migration script? It gets schemas, transformation rules, and golden datasets for verification.

Targeted context produces precise output. Generic context produces generic output.

Spec-driven development

For regulated domains -- banking, insurance, health -- we take this a step further with spec-driven development. Between the reverse-engineering and rebuild phases, business rules and integration contracts are turned into executable specifications: behavior scenarios (Given/When/Then), contract tests for every API boundary, and property-based tests for edge cases.

These specs become the acceptance criteria that AI agents use as guardrails, auditors use to verify compliance, and CI pipelines use to catch regressions immediately. The specification becomes the single source of truth for what the system must do.

Why this should excite engineers, not frighten them

A common reaction to these changes is anxiety: "If AI writes the code, what am I for?"

The answer is: everything that matters. Judgment, domain understanding, architecture decisions, verification, quality -- these are the hard parts of software engineering. They always have been. Writing the code was never the bottleneck; understanding what to write and why was.

Engineers who embrace orchestration -- who learn to prepare context, structure tasks for AI agents, and verify output rigorously -- will find themselves dramatically more productive. A single engineer with well-curated context and good methodology can now deliver what previously required a team of five.

But engineers who insist on typing every line themselves, who resist the shift from instrumentalist to conductor, will find themselves increasingly unable to match the output of their AI-augmented peers. Being a good engineer is not about knowing a particular technology. It is about understanding the business and learning fast.

The question is not whether this transformation is coming. It is already here. The question is whether you will lead it or be overtaken by it.

'''

This is the fourth in a series of five articles based on the talk "Software Will Cost Almost Nothing. What Happens Next?" In the final article, we will reveal the complete methodology that makes AI-accelerated legacy modernization reliable and reproducible.
]]>
<![CDATA[AI Is Collapsing the Cost of Software. Here Are the Numbers.]]> https://blog.lunatech.com/posts/2026-05-12-ai-is-collapsing-the-cost-of-software/ https://blog.lunatech.com/posts/2026-05-12-ai-is-collapsing-the-cost-of-software/ Tue, 12 May 2026 00:00:00 +0000

Every previous industrial revolution replaced muscle. Steam engines, assembly lines, tractors -- each one amplified physical labor and made goods cheaper to produce. The revolution happening right now is different in kind. For the first time, what is being replaced is the ability to produce structured intellectual output: code, documentation, test plans, architecture decisions, migration scripts, compliance reports.

This is not a future scenario. It is already measurable, benchmarked, and accelerating.

The timeline of AI-assisted development

The shift has happened in barely five years. In 2020, AI in software development meant autocomplete -- Copilot-style code suggestions that saved a few keystrokes. By 2022, ChatGPT had replaced Stack Overflow as the first place developers looked for answers. In 2024, AI became embedded in CI/CD pipelines: generating tests, reviewing pull requests, checking architecture consistency. And in 2026, we have entered the era of agentic coding -- AI agents that plan, implement, and verify multi-step tasks autonomously.

Each of these stages represents not just a feature improvement but a qualitative shift in what AI can do. We have gone from "help me write a function" to "here is the specification, the constraints, and the context -- build the module, write the tests, and verify the output."

The numbers do not lie

The benchmarks are clear and consistent across multiple independent studies.

46% of code written by GitHub Copilot users is now AI-generated. A study across 4,000 developers showed a 26% productivity boost. Controlled experiments report 55% faster task completion.

And these are already outdated figures. GPT-4 in 2023 was good at snippets. Claude 3.5 in 2024 could handle full-file rewrites. Claude Code in 2025 introduced agentic workflows -- multi-step tasks with self-correction. Claude Opus 4 brought multi-step planning, self-verification, and parallel agent execution.

Every six months, a generation leap. The curve is not flattening -- it is steepening.

Costs

What becomes economically viable

The real impact is not that developers type less. It is that entire categories of projects that were previously too expensive suddenly become affordable.

Legacy systems that were too expensive to replace? Now within reach. Custom software that was reserved for large budgets? Accessible to smaller companies. Complete test suites that nobody had time to write? Generated automatically.

Consider the concrete numbers:

Project 2020 (Traditional) 2026 (AI-Accelerated)
COBOL Migration (1M lines) $9.1M, 18 months $2-4M, 6-8 months
Custom CRM Development $500K, 12 months $80K, 3 months
Full Test Suite (legacy app) $200K, 6 months $15K, 2 weeks

Same scope. Same quality. Fraction of the cost.

Why this matters for legacy modernization

For decades, the standard answer to "should we modernize?" has been a cost-benefit analysis where the costs were terrifyingly high and the benefits were uncertain. That calculus has fundamentally changed.

The average cost of modernization -- $9.1 million per Deloitte -- was the perfect excuse to do nothing. But when AI can reverse-engineer business rules from a COBOL codebase in two weeks instead of three months, when it can translate modules with verified behavioral equivalence, when it can generate test suites from golden datasets automatically, the cost curve collapses.

This does not mean modernization becomes trivial. It means it becomes feasible. The economic barrier that protected legacy systems from replacement has eroded. What remains is the organizational will to act.

The inaction tax is now unbearable

When action cost millions and took years, inaction could be rationalized. The system works. The risk is too high. We will revisit next quarter.

But when action costs a fraction of what it did, and the alternative is continuing to pay $200K per year just to keep the lights on, the calculation flips. The "inaction tax" -- the cumulative cost of maintaining, patching, and working around a legacy system -- now exceeds the cost of replacing it.

When action costs almost nothing, inaction becomes inexcusable.

In the next article, we will explore what this means for the people who build software. If AI is producing the code, what is the new role of the engineer?

'''

This is the third in a series of five articles based on the talk "Software Will Cost Almost Nothing. What Happens Next?" Read the full series on our blog.
Sources: GitHub Copilot Impact Study (2025), McKinsey Digital Productivity Report (2024), Google DeepMind (2024), Deloitte Legacy Modernization Cost Analysis (2025), Lunatech client data.
]]>
<![CDATA[The Cost of Inaction: Why Standing Still Is the Most Expensive Decision in Tech]]> https://blog.lunatech.com/posts/2026-05-05-the-cost-of-inaction/ https://blog.lunatech.com/posts/2026-05-05-the-cost-of-inaction/ Tue, 05 May 2026 00:00:00 +0000

In 1977, Ken Olsen, the founder of Digital Equipment Corporation, declared: "There is no reason anyone would want a computer in their home." DEC was worth $14 billion at the time. It no longer exists.

This is not a story about prediction failures. It is a story about what happens when organizations see disruption coming and choose to stand still. And if you are running a legacy system in 2026, this story is about you.

Famous last words

The pattern is always the same. A dominant player sees the future but decides that the present is comfortable enough. Kodak invented the digital camera in 1975 -- thirty-seven years before filing for bankruptcy. Blockbuster declined a partnership with Netflix in 2000 for $50 million; Netflix is now worth over $250 billion. BlackBerry dismissed the iPhone as a toy in 2007 and watched its market share fall to zero.

None of these companies lacked engineers, resources, or market intelligence. What they lacked was the willingness to cannibalize their own success in order to survive. They chose the comfort of the status quo over the discomfort of transformation.

And the status quo killed them.

Meanwhile, in 2026

The numbers tell a sobering story. There are still 240 billion lines of COBOL in production worldwide. 43% of banking and insurance systems run on mainframes designed in the 1960s. The average cost to modernize a legacy system has historically been around $9.1 million -- a figure that has served as the perfect excuse to postpone.

Every year, companies spend billions maintaining systems that were obsolete a decade ago: patching vulnerabilities in frameworks that are no longer supported, onboarding developers into codebases that nobody fully understands, and running parallel infrastructure because the old system "still works" even though it cannot integrate with modern tools.

"We'll modernize next year," says every CTO since 2015. Spoiler: next year never comes. And the bill keeps growing.

The three hidden costs

The cost of inaction is rarely visible on a balance sheet. It manifests in three dimensions.

The three hidden costs

The first is technical. Every year of postponement adds another layer of tech debt. Interfaces become more brittle, dependencies more tangled, and the pool of developers who understand the system shrinks. The longer you wait, the harder and more expensive the migration becomes. This is not linear -- it is exponential.

The second is organizational. Companies that do not modernize tend to develop rigid, flat structures where no one owns the decision to change. Committees form. Reports are commissioned. Proof-of-concept projects are launched and quietly shelved. The organizational muscle for transformation atrophies.

The third is personal. For the software engineers working on these systems, inaction means stale skill sets. Still building Struts MVC applications in 2026? Because of inertia? A stale skill set does not land new jobs, and it does not keep old ones interesting. Being a good engineer is not about knowing a particular technology. It is about understanding the business and learning fast.

Europe's particular challenge

There is a cultural dimension to this as well. Europe favors stability, consensus, and regulation. The US moves fast, encourages risk-taking, and rewards bold innovation. The result? Europe has missed leadership in cloud, AI, social media, and platform economics.

This is not because European engineers are less talented. It is because the institutional incentive in Europe is to avoid failure rather than pursue success. And fear of failure leads directly to missed opportunity. Innovation requires courage, not just competence.

Inaction is a choice

The instinct to delay is understandable. Modernization is complex, expensive, and risky. But the cost of not modernizing is also complex, expensive, and risky -- it is just less visible.

Every day a legacy system stays in production is a day of compounding cost: maintenance overhead, security exposure, opportunity cost of features that cannot be built, and the slow erosion of competitive position.

Inaction does not protect you. It just delays the consequences.

The good news? The economics of modernization have changed dramatically. In the next article, we will explore how AI is collapsing the cost of building software -- and why projects that were unthinkable two years ago are now within reach.

'''

This is the second in a series of five articles based on the talk "Software Will Cost Almost Nothing. What Happens Next?" Read the full series on our blog.
]]>
<![CDATA[Software Will Cost Almost Nothing. What Happens Next?]]> https://blog.lunatech.com/posts/2026-04-28-software-will-cost-almost-nothing/ https://blog.lunatech.com/posts/2026-04-28-software-will-cost-almost-nothing/ Tue, 28 Apr 2026 00:00:00 +0000

Every industrial revolution until now replaced muscle. The steam engine, the assembly line, the tractor -- each one amplified physical labor and made goods cheaper to produce. But the revolution we are living through right now is fundamentally different. For the first time, what is being replaced is the ability to produce structured intellectual output: code, documentation, test plans, architecture decisions, migration scripts, compliance reports. All of these can now be produced by AI agents.

The implications for our industry are staggering. If software -- the most valuable and most expensive asset of the modern enterprise -- can be produced at a fraction of today's cost, then what changes?

Everything.

The cost of inaction is no longer abstract

At RivieraDev 2025, my co-speaker Quentin Adam and I delivered a keynote titled "The Cost of Inaction." The thesis was simple: standing still is the most expensive decision a company can make.

The evidence was everywhere. Kodak invented the digital camera in 1975 and filed for bankruptcy in 2012. Blockbuster declined a partnership with Netflix in 2000 and closed 9,000 stores. BlackBerry dismissed the iPhone in 2007 and holds zero market share today. None of these companies lacked talent or technology. They lacked the courage to act.

Meanwhile, in 2026, 240 billion lines of COBOL remain in production. 43% of banking and insurance systems still run on mainframes. The average cost to modernize? $9.1 million -- until now.

The hidden costs of inaction are technical (accumulated tech debt, lost innovation capacity), organizational (flat structures that lack ownership), and personal (stalled careers, missed opportunities). Inaction does not protect you. It just delays the consequences.

But what if the cost of action collapses?

This is where the story takes a dramatic turn. AI-assisted software development has moved from autocomplete in 2020 to fully agentic coding in 2026. The numbers are unambiguous: 46% of code written by Copilot users is AI-generated, productivity gains of 26% across thousands of developers, and 55% faster task completion in controlled experiments. And these benchmarks are already months old -- every quarter brings another generation leap.

Consider what this means in practical terms. A COBOL migration that cost $9.1 million and took 18 months in 2020 can now be completed for $2-4 million in 6-8 months. A custom CRM that required $500K and a year of development can be built for $80K in three months. A full test suite for a legacy application that would have cost $200K and six months of manual work can be generated for $15K in two weeks.

Same scope. Same quality. Fraction of the cost.

When action costs almost nothing, inaction becomes inexcusable.

Engineers become orchestrators

But there is a catch. AI does not mean pressing a button and watching the magic happen. Without structure, AI produces plausible but generic output that needs extensive rework. The difference between success and failure lies entirely in methodology.

The new role of the software engineer is not to type code. It is to orchestrate AI agents -- curating context, defining quality gates, structuring work, verifying output, and accumulating knowledge over time. Like a conductor in front of an orchestra, the engineer sets the tempo, the dynamics, and the interpretation. The performance depends entirely on the score.

This is captured in a simple equation:

AI Output Quality = AI Capability x Context Quality
equation.png

AI capability improves every quarter. You cannot control it. Context quality is the lever you own. This is where engineering discipline makes the difference between a demo and production-grade software.

A battle-tested methodology

At Lunatech, we have spent the past year building a methodology that makes AI-accelerated modernization reliable, reproducible, and predictable. Our Legacy Modernization with AI framework is built on seven phases -- Discover, Reverse-Engineer, Re-Architect, Rebuild, Migrate, Harden, Handover -- each with clear deliverables and exit gates.

At the heart of the framework is the Claude (or Copilot) Context Pack: a versioned, curated knowledge base with seven layers (System, Code, Data, Business, Architecture, Test, Decision) that feeds every AI task with precisely the right context. A dedicated AI engineer builds, curates, and maintains it throughout the engagement.

The result? Small teams of 2-4 senior engineers plus a dedicated AI engineer consistently outperform larger traditional teams. Parity is verified through behavioral equivalence testing and parallel running -- not assumed. And the context pack is transferred to the client as a durable asset at handover: the living documentation the legacy system never had.

What this means for you

If you are a CTO sitting on a legacy system and thinking "we'll modernize next year," know that next year never comes. And the bill keeps growing. If you are a software engineer still building the same kind of applications with the same kind of tools, your skill set is becoming stale. The competitive advantage is shifting from code production to speed and judgment.

The cost of software is collapsing. The skill is no longer writing code -- it is orchestrating agents. And methodology is what separates production-grade output from demos.

Take the first step. Start something.

'''

This is the first in a series of five articles based on my talk "Software Will Cost Almost Nothing. What Happens Next?" For more details on each theme, follow the series on our blog and on LinkedIn.
]]>
<![CDATA[Devoxx France 2026]]> https://blog.lunatech.com/posts/2026-04-24-devoxx-france-2026/ https://blog.lunatech.com/posts/2026-04-24-devoxx-france-2026/ Fri, 24 Apr 2026 00:00:00 +0000

I spent two days at Devoxx France 2026 at the Palais des Congrès in Paris. The buzz words of this edition were hard to miss: AI, Vibe Coding, Claude, Agentic, Context, Determinism. Out of sixteen talks I attended, more than half dealt with AI in some form. The conference carried genuine optimism : barriers are falling, tools are powerful, now is the time to experiment. But the talks that stuck with me most went further. They examined where AI breaks, what it costs, and what discipline it demands. Here is what I took away.

AI Will Solve Everything... Right?

Devoxx opened with keynotes spanning from enthusiasm to existential warning.

Prompt, Ship, Test

Nicolas Grenié set the tone on day one. The barriers to building are collapsing. Solo developers ship prototypes that would have taken teams months. Product Managers pick up Figma and Claude and start dreaming. His message: experiment now, ship, test. Both the tools and the way we use them will keep changing. Do not wait.

The Right to Be Lazy

Jean-Gabriel Ganascia, a philosopher and engineer, delivered his entire keynote without a single slide, a rare move at Devoxx. He opened with Paul Lafargue's 19th-century manifesto The Right to Be Lazy: if machines handle the drudgery, humans should work three hours a day and spend the rest thinking.

Generative AI seems to fulfill that promise. It reads thousands of pages of reports, produces them, makes decisions. We keep only the rewarding tasks. "From that perspective, it's wonderful," Ganascia said.

Then he flipped the argument and laid out four risks:

  1. Boredom. If we delegate cognitive work, will we have enough to keep our minds occupied? He quoted Baudelaire's Fleurs du mal, "It is Boredom!", as a warning.

  2. The capitalist hydra won't wither away. "Be honest — do you really believe that? Not long ago Meta laid off 8,000 people." AI will make large companies more profitable, not free workers.

  3. De-skilling. If we stop learning to read, write, and code on our own, what remains?

  4. Moral alignment as censorship. Who decides what an AI may or may not say? Ganascia mentioned France Travail's plan for AI-assisted annual interviews, a project that raises questions about large-scale behavioral control.

His closing warning: after the Bronze Age, the Iron Age, and the Information Age, we risk entering the Age of Falsification and Control.

Vibe Coding Meets Reality

Marjory Canonne, who advises SMEs on AI adoption, described the gap between expectations and outcomes. Many companies approach AI in FOMO mode, hoping for immediate ROI. When she explains what AI actually is and what it costs, many lose interest.

Her advice to companies: start by systematizing internal knowledge. Mine your data, capitalize on experience, speed up information access across the organization. That delivers value. Jumping straight to customer-facing AI products does not. During Q&A, a developer asked: what do you do when a Product Manager vibe-codes a prototype, then says "we can cut your budget, one dev will clean this up"? She responded that some companies will have to learn that the hard way. Grenié added with humour: "Where we used to have 500 days to deliver, we'll now have 5 days of vibe-coded prototype and 495 days cleaning up the mess."

My own concern here: there is no certainty about the future of LLMs. Today AI is cheap. Providers sell at a loss. Putting an AI-as-a-service (GPT, Claude, Gemini) at the core of a product's business domain means accepting uncertainty by design: non-deterministic behavior, provider version changes, price hikes, or disappearance.

Datacenters and the Sovereignty Paradox

Loup Cellard, an anthropology researcher at Sciences Po, shifted the conversation to physical infrastructure. His talk mapped how submarine cables shape datacenter deployments in France. Marseille (7th best-connected city in the world) and Bordeaux (where Meta's "Amitié" cable arrives) concentrate the installations. The result: conflicts over energy between public transit and datacenters, land speculation, and a sovereignty paradox. Local authorities told Cellard they do not understand the government's position: France talks about digital sovereignty while welcoming US players, sometimes on publicly funded infrastructure.

Engineering with AI Agents: Discipline Over Hype

If the keynotes asked "should we?", the afternoon talks asked "how do we do it properly?"

Four Patterns for Agentic AI

Guillaume Laforge presented four design patterns for AI agents, each addressing the same core problem: LLMs degrade when you give them too much at once.

  • Progressive Disclosure. Organize agent capabilities into skills, each with an abstract header and a detailed body. A SKILL.md index lets the agent load only what it needs. (See Deslopify skills on GitHub.)

  • Hierarchical Decomposition. Break work into sequential steps with specialized agents. One writes an article, another de-slops it. The tradeoff is more latency and sometimes more tokens.

  • LLM-as-Judge. Use one model to evaluate another's output. Three summaries from Gemini 2.5 Flash Lite plus one review from Gemini 2.5 Flash costs roughly $0.80, versus $2.00 for a single Gemini 3.1 Pro summary, and produces better results.

  • GOAP (Goal-Oriented Action Planning). A supervisor LLM receives a high-level objective and orchestrates specialized agents. Laforge demonstrated this with LangChain4j.

Constrain the Puzzle

Cyrille Martraire and Olivier Penhoat presented a legacy migration case study where they used AI to generate end-to-end API tests.

Their first approach was to have the AI run the tests by querying both APIs and comparing responses. It worked 100% of the time, but was slow, expensive, and non-deterministic. JSON payloads of ~400 KB ate the context window, and results varied between runs.

Their second approach worked: the AI generates the tests instead of running them. They constrained the problem with two files: a .yml describing test scenarios and a pivot-format.spec.md specifying how to map old API responses to new ones. The AI fills in only the test implementation. Expensive once (generation), nearly free afterwards (standard execution). The principle: constrain the AI to fill one piece of the puzzle, not generate the whole puzzle. The more precise the framework you provide, the more deterministic the output.

Context Engineering for Deterministic AI

Benoît Fontaine's talk on taming AI agents reinforced the same idea from the tooling side. His key points:

  • Performance degrades not because of context size, but because of what is in the context. Compact at 60%, never exceed 80%. If auto-compaction kicks in, the session is dead.

  • Maintain at least two files: AGENTS.md (cross-tool source of truth with architecture rules, conventions, workflows, strict rules) and CLAUDE.md (tool-specific adapter). Keep each under 200 lines.

  • Place scoped context files in relevant directories (/frontend, /backend).

  • Ideal session workflow: research, compact, plan, compact, implement.

His closing line resonated: "AI is an amplifier of the current state. If the documentation and specs were already clean, the AI output will be clean." In other words, operational maturity matters more than which model you pick.

Claude Code in 30 Minutes

Erwan Gereec delivered 30 Claude Code tips in 30 minutes. A few stood out:

  • /init analyzes the codebase and initializes CLAUDE.md, a natural starting point for context engineering.

  • /compact keep the developed API and remove explorations targets compaction instead of blind compression.

  • /insights generates a detailed HTML report analyzing your usage habits, with personalized tips.

  • Custom skills live in .claude/skills/<name>/SKILL.md for repeatable workflows.

Java Keeps Shipping

AI dominated, but Java had its own strong showing.

Value Types: Codes Like a Class, Works Like an Int

If you hadn't already followed java incoming evolutions, Rémi Forax and Clément de Tastes presented Project Valhalla , the largest refactoring in JDK history (2,665 files modified, +200k lines). The core idea : using a new value keyword before class or record gives up the object's identity in exchange for memory flattening and scalarization.

Using a Mandelbrot visualisation tool, they showed that switching from record to value record reduced GC pressure by 143x and memory usage by 88%. The JIT compiler can scalarize value types, copying field values directly into CPU registers because the objects are immutable and identity-free.

Beyond raw performance, this will inevitably change the way we code by using more immutable objects and data structures. We must be aware now that current JDK @ValueBased annotated classes will automatically become value classes (no recompilation needed for old libraries). Furthermore, a new ! (bang) operator will guarantee non-nullity for array flattening, and == comparison will be possible on value objects (in that case, this is not a reference comparison, but a value comparison). But there are traps though : == on a value class holding a String field compares the reference, not the content. Moreover, synchronized won't work because the lock relies on the object header, which won't exist on value classes.

Structured Concurrency

José Paumard ran a 3-hour hands-on lab on the Structured Concurrency API (Project Loom), available in JDK 27 early access. Virtual threads (JDK 21) made threads cheap to create, but Java still lacked a concurrency model to match. Running parallel tasks meant either chaining CompletableFuture callbacks or adopting a reactive framework — both sacrifice readability and stack traces. Structured Concurrency fills that gap: fork tasks on virtual threads, write sequential-looking code, and let the scope handle cancellation and error propagation automatically.

try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
    scope.fork(taskA);
    scope.fork(taskB);
    scope.join();
}

Multiple Joiner strategies handle success, failure, and timeouts. Structured Concurrency isn’t new as an idea. What Project Loom brings is making it finally practical at scale thanks to virtual threads, because platform threads are expensive to create and do not scale well when used in large numbers.

In Brief

  • Victor Rentea delivered the funniest talk of the conference on Event-Driven Architecture pitfalls, covering ordering, partition keys, phantom writes, and idempotency. It moved too fast to capture in notes. Worth rewatching on YouTube .

  • Thomas Pierrain and Julien Topçu presented "The Hive." Starting from a Big Ball of Mud, they showed how to modularize a monolith through vertical slicing, then isolate each module as a hexagon with adapters that translate types between modules so domain models never leak from one hexagon to another. Once that structure is in place, extracting a module into a microservice becomes straightforward.

Conclusion

Build up AI skills now. The tools are powerful and priced below cost; that window will close. But tooling alone won't save you, as clean documentation and clear specs make AI output better, while sloppy inputs produce sloppy outputs, faster. AI needs a clear framework to fill in the missing pieces; constraining the puzzle is the key to tackle its lack of determinism. And Java keeps evolving. Value types, structured concurrency, and virtual threads address real performance and concurrency pain points. Valhalla alone justifies paying attention to the next JDK releases.

]]>
<![CDATA[Change Detection Strategies in Angular: Zones and Signals]]> https://blog.lunatech.com/posts/2025-11-05-change-detection-strategies-in-angular-zones-and-signals/ https://blog.lunatech.com/posts/2025-11-05-change-detection-strategies-in-angular-zones-and-signals/ Wed, 05 Nov 2025 00:00:00 +0000

Understand Angular’s change detection mechanism and how to optimize performance using the OnPush strategy. Learn how to use Signals for more granular reactivity. This article provides a practical mental model and shows how to apply it with Zone.js, OnPush, and Signals, along with a clear path toward building zoneless applications when you’re ready.

The mental model: how Angular knows to update

At a high level, Angular renders your component tree into a graph of views. A change detection pass checks template bindings and updates the DOM where something changed. Something has to tell Angular when to do that pass:

  • With Zone.js (the default), Angular patches async APIs (Promises, setTimeout, DOM events, etc.) and schedules checks automatically after those tasks.

  • With signals, writes to a signal schedule the right components to re-render even without Zone.js.

  • With OnPush, Angular narrows when a component is checked: on input reference changes, events in that component, async pipe emissions, and signal writes.

Once you see what triggers checks, you can guide Angular to do less work and update only when it matters.

Working with Zone.js (default)

Zone.js intercepts most async boundaries and calls into Angular to run change detection. It’s a safe default and reduces boilerplate, especially for teams migrating legacy code.

You can configure Zone.js coalescing to reduce redundant checks:

// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    // Collapses multiple events/microtasks into fewer change detection cycles.
    provideZoneChangeDetection({
      eventCoalescing: true,
      runCoalescing: true,
    }),
    provideHttpClient(),
  ],
};

For performance-sensitive work (e.g., animations or polling), run it outside Angular to avoid thrashing, and re-enter only when UI state actually changes.

import { 
  Component, 
  NgZone, 
  ChangeDetectionStrategy, 
  signal, 
  inject,
  OnInit,
  OnDestroy 
} from '@angular/core';

@Component({
  selector: 'cpu-heavy',
  standalone: true,
  template: `
    <p>Frames: {{ frames() }}</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CpuHeavyComponent implements OnInit, OnDestroy {
  private zone = inject(NgZone);

  // Signal handles reactivity; no CDR needed
  private _frames = signal(0);
  frames = this._frames.asReadonly();

  private intervalId?: ReturnType<typeof setInterval>;

  ngOnInit() {
    this.zone.runOutsideAngular(() => {
      let n = 0;
      this.intervalId = setInterval(() => {
        // Update signal outside Angular zone
        // Signals batch updates automatically
        this._frames.set(++n);
        
        if (n > 3000) {
          clearInterval(this.intervalId);
        }
      }, 16);
    });
  }

  ngOnDestroy() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
    }
  }
}

Embracing OnPush for predictable performance

OnPush reduces unnecessary checks and nudges you toward immutable data and explicit updates. Angular will check an OnPush component when:

  • An Input reference changes (immutability shines here).

  • The component fires an event or an async pipe emits.

  • A bound signal writes (signals integrate with the scheduler).

  • You explicitly call markForCheck or detectChanges.

Use immutable updates for inputs and track nodes for stable lists.

import { Component, ChangeDetectionStrategy, input } from '@angular/core';

type User = { id: number; name: string };

@Component({
  selector: 'user-list',
  standalone: true,
  template: `
    <ul>
      @for (u of users(); track u.id) {
        <li>{{ u.name }}</li>
      }
    </ul>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserListComponent {
  // Signal-based input (works great with OnPush)
  users = input<ReadonlyArray<User>>([]);
}
// parent.component.ts
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { UserListComponent } from './user-list.component';

type User = { id: number; name: string };

@Component({
  selector: 'parent',
  standalone: true,
  imports: [UserListComponent],
  template: `
    <button (click)="add()">Add</button>
    <button (click)="mutate()">Mutate (bad)</button>

    <user-list [users]="users()"></user-list>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ParentComponent {
  private _users = signal<ReadonlyArray<User>>([
    { id: 1, name: 'Maya' },
    { id: 2, name: 'Dee' },
  ]);
  users = this._users.asReadonly();

  // Correct: immutable update changes the reference and triggers OnPush
  add() {
    const currentIds = this.users().map(u => u.id);
    const nextId = currentIds.length > 0 ? Math.max(...currentIds) + 1 : 1;
    this._users.update(arr => [...arr, { id: nextId, name: 'New User' }]);
  }

  // Bad: mutates in place; OnPush children won't see a different reference
  mutate() {
    // as any to showcase the pitfall (avoid in real code)
    (this.users() as any)[0].name = 'Mutated';
    // The UI may not update; prefer immutable patterns.
  }
}

If you're not using signals and you must update UI from a callback that Angular doesn't know about, call markForCheck.

import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@angular/core';

@Component({
  selector: 'ws-status',
  standalone: true,
  template: `Status: {{ status }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WebSocketStatusComponent implements OnInit {
  status = 'disconnected';
  constructor(private cdr: ChangeDetectorRef) {}

  ngOnInit() {
    const ws = new WebSocket('wss://example.org');
    ws.onopen = () => { this.status = 'connected'; this.cdr.markForCheck(); };
    ws.onclose = () => { this.status = 'disconnected'; this.cdr.markForCheck(); };
  }
}
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';

@Component({
  selector: 'ws-status',
  standalone: true,
  template: `Status: {{ status }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WebSocketStatusComponent {
  status = 'disconnected';
  constructor(private cdr: ChangeDetectorRef) {}

  ngOnInit() {
    const ws = new WebSocket('wss://example.org');
    ws.onopen = () => { this.status = 'connected'; this.cdr.markForCheck(); };
    ws.onclose = () => { this.status = 'disconnected'; this.cdr.markForCheck(); };
  }
}

Signals (intro): fine‑grained reactivity without heavy lifting

Signals give you a declarative, dependency-tracked model for state. Reading a signal in a template links it to the view; writing to that signal schedules the right view to update. This makes change detection more targeted and pairs naturally with OnPush or even zoneless.

import { Component, ChangeDetectionStrategy, signal, computed, effect } from '@angular/core';

type Todo = { id: number; title: string; done: boolean };

@Component({
  selector: 'todo-panel',
  standalone: true,
  template: `
    <input placeholder="Filter..." [value]="filter()" (input)="filter.set(($event.target as HTMLInputElement).value)" />

    <p>Completed: {{ completedCount() }} / {{ todos().length }}</p>

    <ul>
      @for (t of filteredTodos(); track t.id) {
        <li>
          <label>
            <input type="checkbox" [checked]="t.done" (change)="toggle(t.id)" />
            {{ t.title }}
          </label>
        </li>
      }
    </ul>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoPanelComponent {
  private _todos = signal<Todo[]>([
    { id: 1, title: 'Ship dashboard', done: false },
    { id: 2, title: 'Write docs', done: true },
  ]);
  todos = this._todos.asReadonly();

  filter = signal('');
  filteredTodos = computed(() => {
    const q = this.filter().toLowerCase();
    return this.todos().filter(t => t.title.toLowerCase().includes(q));
  });
  completedCount = computed(() => this.todos().filter(t => t.done).length);

  toggle(id: number) {
    this._todos.update(ts => ts.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
  }

  // Optional: side effects
  log = effect(() => {
    console.debug('Todos changed:', this.todos());
  });
}

Interop with RxJS is straightforward; toSignal turns an Observable into a signal that drives OnPush views.

import { toSignal } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';

@Component({ /* ... */ })
export class ClockComponent {
  now = toSignal(
    // Emits a new Date every second
    new Observable<Date>(sub => {
      const id = setInterval(() => sub.next(new Date()), 1000);
      return () => clearInterval(id);
    }),
    { initialValue: new Date() }
  );
}

Because writing to signals schedules view updates, you often don’t need ChangeDetectorRef calls. The result is simpler, more predictable change detection.

Zoneless change detection: when you're ready to drop Zone.js

Zoneless mode lets Angular skip patching browser APIs (no Zone.js) and rely purely on explicit reactive triggers (signals, template events, async pipe, router). By Angular 20, zoneless is mature enough for production in well-structured, signal‑driven apps.

Use the stable provider if it exists; otherwise keep using the experimental one until your project confirms the upgrade path.

// app.config.ts
import { ApplicationConfig, provideHttpClient } from '@angular/common/http';
import {
  // Verify which one exists in your installed version:
  // If Angular 20 exposes `provideZonelessChangeDetection`, prefer it.
  provideZonelessChangeDetection,               // <-- Confirm availability
  provideExperimentalZonelessChangeDetection,   // <-- Fallback if stable not present
} from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    // Use ONE of the following:
    // provideZonelessChangeDetection(),
    provideExperimentalZonelessChangeDetection(), // ← Remove when stable provider confirmed
    provideHttpClient(),
  ],
};

With zoneless, Angular still updates views when:

  • Template events fire (click, input, etc.).

  • A signal is written (set/update).

  • AsyncPipe emits (it marks its view manually).

  • Router navigation completes.

Other async sources (WebSocket, timers, SDK callbacks): funnel them through signals (or Observables bound via async pipe / toSignal). Avoid manual ChangeDetectorRef unless integrating with legacy patterns.

import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map, startWith } from 'rxjs';

@Component({
  selector: 'zoneless-demo',
  standalone: true,
  template: `
    <p>Server time: {{ time() }}</p>
    <p>Clicks: {{ clicks() }}</p>
    <button (click)="inc()">Click me</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZonelessDemoComponent {
  time = toSignal(
    interval(1000).pipe(
      map(() => new Date().toLocaleTimeString()),
      startWith(new Date().toLocaleTimeString()),
    ),
    { initialValue: new Date().toLocaleTimeString() },
  );

  private _clicks = signal(0);
  clicks = this._clicks.asReadonly();

  inc() {
    this._clicks.update(n => n + 1);
  }
}

If you interact with non-Angular callbacks (e.g., third‑party SDKs), prefer to set signals inside the callback. That write will schedule the needed view updates.

Practical checklist

  • Prefer OnPush for all app components; combine with immutable data.

  • Use signal-based inputs (input()) in leaf components for ergonomic, fine-grained updates.

  • Store local UI state as signals; use computed for derivations and effect for side effects.

  • In Zone.js apps, isolate heavy loops with runOutsideAngular and re-enter only on state writes.

  • In zoneless apps, ensure all UI-affecting async work flows through signals or async pipe.

  • Track list items by a stable key to avoid re-render churn.

  • Use provideZoneChangeDetection with coalescing in legacy/mixed environments; move toward provideExperimentalZonelessChangeDetection when your reactive pathways are solid.

Troubleshooting gotchas

  • “My OnPush child didn’t update.” Ensure the Input reference changes (immutable update) or the child reads a signal that you wrote to.

  • “Zoneless didn’t repaint after a callback.” Convert that callback’s data to a signal or trigger an async pipe emission; avoid manual detectChanges unless you know the implications.

  • “Perf regressions after refactor.” Check for unintended synchronous effects that write many signals in a loop; batch updates if needed, or compose a single state write.

Conclusion

Change detection in Angular is powerful because you can choose the right tool for the job. Zone.js gives you simplicity, OnPush gives you predictability and performance, and signals give you precise, maintainable reactivity. Together, they help you ship fast UIs without guesswork, and you can progressively adopt a zoneless model when your state is signal-driven.

Next Steps

  • Audit your components: enable OnPush and fix any mutable input flows.

  • Introduce signals for local state and derived values; replace ad‑hoc ChangeDetectorRef calls.

  • Wrap recurring async sources (timers, sockets) with toSignal or async pipe.

  • Experiment with provideExperimentalZonelessChangeDetection in a feature slice to validate your reactive pathways.

  • Read the Angular guides on signals and zoneless change detection to deepen your understanding.

]]>
<![CDATA[Getting Started with Angular 20 CLI and Standalone Components]]> https://blog.lunatech.com/posts/2025-10-27-getting-started-with-angular-20-cli-and-standalone-components/ https://blog.lunatech.com/posts/2025-10-27-getting-started-with-angular-20-cli-and-standalone-components/ Mon, 27 Oct 2025 00:00:00 +0000

Learn how to initialize and structure a new Angular 20 project using the Angular CLI. Understand standalone components and the simplified bootstrapping process. This guide focuses on a clean project setup, modern routing, and practical patterns you can reuse in real projects.

Prerequisites

  • Node.js 24+ (LTS recommended) and npm or pnpm

  • Angular CLI 20 installed globally: npm i -g @angular/cli@20

  • Familiarity with TypeScript and Angular fundamentals

Create a new project with the Angular CLI

The Angular CLI scaffolds a production-grade baseline with sensible defaults. Standalone components are the default in Angular 20, which means no NgModules are required for most apps.

// Terminal
// Create a new app with routing and SCSS. Use --ssr to scaffold server-side rendering.
ng new lt-dashboard --routing --style=scss
cd lt-dashboard
ng serve

Open http://localhost:4200 and confirm the app runs. The CLI gives you fast dev server reloads, TypeScript checks, and a ready-to-extend project.

Project structure that scales

A small, predictable layout keeps cognitive load low as features grow.

// Suggested structure (simplified)
src/
  main.ts
  index.html
  styles.scss
  environments/
    environment.ts
    environment.prod.ts
  app/
    app.component.ts
    app.routes.ts
    features/
      home/
        home.component.ts
      projects/
        projects.component.ts
  • app.routes.ts holds your route config.

  • Each feature gets its own folder with a standalone component.

  • environments holds build-time configuration.

Simplified bootstrapping with bootstrapApplication

Standalone components and functional providers eliminate boilerplate. The entry point is lean and explicit.

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, HttpInterceptorFn, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

// Example functional interceptor (adds a version header)
const versionHeaderInterceptor: HttpInterceptorFn = (req, next) =>
  next(req.clone({ setHeaders: { 'X-App-Version': '1.0.0' } }));

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([versionHeaderInterceptor])),
    provideAnimations(),
    // If you generated the project with --ssr, enable hydration:
    // provideClientHydration(),
  ],
}).catch(err => console.error(err));
  • provideRouter wires your routing without a root NgModule.

  • provideHttpClient configures HttpClient with tree-shakable features like withInterceptors.

  • provideAnimations enables Angular animations globally.

  • withComponentInputBinding lets you bind route params/data directly to component inputs.

Routing with lazy standalone components

Define routes in a single place and lazy-load components with loadComponent.

// src/app/app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./features/home/home.component').then(m => m.HomeComponent),
    title: 'Home',
  },
  {
    path: 'projects',
    loadComponent: () =>
      import('./features/projects/projects.component').then(m => m.ProjectsComponent),
    title: 'Projects',
  },
  { path: '**', redirectTo: '' },
];

This setup is fast to read and easy to evolve. Each path points directly at a standalone component without an intermediate NgModule.

A minimal root component

Keep your root component focused on layout and navigation.

// src/app/app.component.ts
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, RouterLink, RouterLinkActive],
  styles: [`
    nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #eee; }
    a.active { font-weight: 600; text-decoration: underline; }
    main { padding: 1rem; }
  `],
  template: `
    <nav>
      <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
      <a routerLink="/projects" routerLinkActive="active">Projects</a>
    </nav>
    <main>
      <router-outlet />
    </main>
  `,
})
export class AppComponent {}

Build a real feature: Home with forms and new control flow

Use reactive forms and the modern control flow syntax (@if, @for) to keep templates expressive and predictable.

// src/app/features/home/home.component.ts
import { Component, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';

type Project = { id: number; name: string };

@Component({
  imports: [ReactiveFormsModule],
  styles: [`
    form { display: flex; gap: 0.5rem; margin: 1rem 0; }
    input { min-width: 240px; }
    ul { padding-left: 1.25rem; }
  `],
  template: `
    <h2>Welcome</h2>

    <form [formGroup]="searchForm" (ngSubmit)="onSearch()">
      <input formControlName="q" type="text" placeholder="Search projects" />
      <button type="submit">Search</button>
      <button type="button" (click)="reset()">Reset</button>
    </form>

    @if (results().length === 0) {
      <p>No results found.</p>
    } @else {
      <ul>
        @for (p of results(); track p.id) {
          <li>{{ p.name }}</li>
        }
      </ul>
    }
  `,
})
export class HomeComponent {
  private readonly allProjects: Project[] = [
    { id: 1, name: 'Roadmap' },
    { id: 2, name: 'UI Kit' },
    { id: 3, name: 'Infra' },
  ];

  results = signal<Project[]>(this.allProjects);

  constructor(private fb: FormBuilder) {}

  searchForm = this.fb.nonNullable.group({ q: '' });

  onSearch(): void {
    const q = this.searchForm.value.q?.toLowerCase() ?? '';
    this.results.set(
      this.allProjects.filter(p => p.name.toLowerCase().includes(q))
    );
  }

  reset(): void {
    this.searchForm.reset({ q: '' });
    this.results.set(this.allProjects);
  }
}
  • signal gives you ergonomic local state without services when you don’t need them.

  • The new control flow removes structural directive noise while remaining explicit.

A lazy projects page

Keep feature components focused and stateless by default.

// src/app/features/projects/projects.component.ts
import { Component } from '@angular/core';

@Component({
  template: `
    <h2>Projects</h2>
    <p>This is a lazy-loaded page. Add tables, filters, or charts here.</p>
  `,
})
export class ProjectsComponent {}

The route configuration we defined earlier will load this component on demand.

HTTP setup with functional interceptors

You already provided HttpClient in main.ts. Add domain-specific concerns via functional interceptors as your app grows (auth tokens, correlation IDs, error normalization). They compose with withInterceptors and stay tree-shakable.

// Example: src/app/core/auth.interceptor.ts (optional)
import { HttpInterceptorFn } from '@angular/common/http';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = localStorage.getItem('token');
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

// Then in main.ts:
// provideHttpClient(withInterceptors([authInterceptor, versionHeaderInterceptor]))

Keep cross-cutting code small, testable, and colocated in core/.

Environment-driven configuration

Use build-time environments for API base URLs and toggles. The CLI swaps files based on configuration.

// src/environments/environment.ts
export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000/api',
};

// src/environments/environment.prod.ts
export const environment = {
  production: true,
  apiUrl: 'https://api.example.com',
};

Access where needed:

// Anywhere in app code
import { environment } from '../environments/environment';
// http.get(`${environment.apiUrl}/projects`)

CLI commands you’ll use daily

  • Generate features: ng g component app/features/activity

  • Add utilities: ng g interceptor core/logging

  • Run locally: ng serve

  • Build for production: ng build --configuration production

  • Run unit tests: ng test

The Angular CLI keeps project setup consistent while remaining flexible.

Optional: Server-side rendering (SSR)

If you create your project with --ssr, the CLI scaffolds an SSR server and hydration for you. In main.ts, include provideClientHydration() so the browser picks up the server-rendered DOM without re-rendering. SSR improves Time to First Byte (TTFB), SEO, and perceived performance for content-heavy routes.

Why this structure works

  • Standalone components reduce indirection and speed up onboarding.

  • A single routes file gives you a map of the app at a glance.

  • Providers in main.ts make cross-cutting behavior explicit.

  • Feature-first folders keep implementation details close to their templates and styles.

Troubleshooting and tips

  • Type errors on control flow (@if/@for): ensure your component template uses the new Angular control flow (Angular 17+). No extra imports are needed.

  • RouterLinkActive not working on ="" for the root link.

  • Interceptor order: interceptors run in the order provided for requests, and in reverse for responses. Put auth/correlation early; place global error handling last to see the final response state.

Conclusion

Angular 20 cuts the ceremony: standalone components by default, functional providers, and minimal bootstrapping. Organize by feature, keep routes clear, and scale without the bloat. The CLI does the grunt work, you build features.

Next Steps

  • Add a core/ directory for cross-cutting concerns (interceptors, guards, tokens).

  • Introduce a shared/ library for reusable UI primitives.

  • Wire HttpClient to a real backend and centralize API calls behind a data-access service.

  • Enable SSR (--ssr) and hydration for content-rich pages.

  • Adopt signals in more places thoughtfully: state local to a component is a good fit; shared, cross-route state still belongs in services.

]]>
<![CDATA[Routing in Angular 20: Basics to Navigation Guards]]> https://blog.lunatech.com/posts/2025-10-22-routing-in-angular-20-basics-to-navigation-guards/ https://blog.lunatech.com/posts/2025-10-22-routing-in-angular-20-basics-to-navigation-guards/ Wed, 22 Oct 2025 00:00:00 +0000

Master client-side routing with Angular’s Router. Learn route configuration, navigation, guards, and Angular 20’s asynchronous redirect improvements.

Why the Router still matters

Routing is the backbone of user flow. It defines how screens load, what data they need, and how access is enforced. A reliable setup pays off when your app scales and your team grows. In Angular 20, the Angular Router keeps moving in the right direction: clearer configuration, better async flows, and ergonomics that prioritize maintainability.

This guide walks through a practical baseline: route configuration, links, programmatic navigation, route guards, and async redirects, all with standalone components and functional APIs.

A minimal, modern router setup

Use standalone APIs and provideRouter for a clean bootstrap.

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      routes,
      withPreloading(PreloadAllModules),
    ),
  ],
});

Define your routes in a dedicated file. Prefer lazy-loading to keep initial bundles lean.

// app/app.routes.ts
import { Routes } from '@angular/router';
import { authCanActivate, authCanMatch, unsavedCanDeactivate } from './auth/guards';

export const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },

  {
    path: 'home',
    title: 'Home',
    loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent),
  },
  {
    path: 'dashboard',
    title: 'Dashboard',
    canActivate: [authCanActivate],
    loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
  },
  {
    path: 'projects',
    canMatch: [authCanMatch],
    loadChildren: () => import('./features/projects/projects.routes').then(m => m.PROJECTS_ROUTES),
  },
  {
    path: 'settings',
    title: 'Settings',
    canDeactivate: [unsavedCanDeactivate],
    loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent),
  },
  {
    path: 'login',
    title: 'Sign in',
    loadComponent: () => import('./features/auth/login.component').then(m => m.LoginComponent),
  },
  {
    path: '**',
    title: 'Not Found',
    loadComponent: () => import('./shared/not-found/not-found.component').then(m => m.NotFoundComponent),
  },
];

A realistic feature route with params and lazy-loading

For features like Projects, lazy load the route definition and child screens.

// features/projects/projects.routes.ts
import { Routes } from '@angular/router';

export const PROJECTS_ROUTES: Routes = [
  {
    path: '',
    title: 'Projects',
    loadComponent: () => import('./list/projects-list.component').then(m => m.ProjectsListComponent),
  },
  {
    path: ':id',
    title: 'Project Details',
    loadComponent: () => import('./details/project-details.component').then(m => m.ProjectDetailsComponent),
  },
];

In a details component, read route params. Use clean, testable patterns.

// features/projects/details/project-details.component.ts
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { ProjectService } from '../data/project.service';

@Component({
  standalone: true,
  selector: 'app-project-details',
  template: `
    @if (project(); as data) {
      <h2>{{ data.name }}</h2>
      <button (click)="goBack()">Back</button>
    } @else {
      <p>Project not found.</p>
    }
  `,
})
export class ProjectDetailsComponent {
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private service = inject(ProjectService);

  private paramMap = toSignal(this.route.paramMap, { initialValue: this.route.snapshot.paramMap });
  id = computed(() => this.paramMap()?.get('id') ?? null);

  project = toSignal(this.service.getProjectStream(this.route.paramMap), { initialValue: null });

  goBack() {
    this.router.navigate(['../'], { relativeTo: this.route });
  }
}

Notes:

  • Use toSignal when a component benefits from reactive values in the template.

  • Keep navigation relative to avoid hardcoding URLs.

Programmatic navigation that reads well

When you need to route from code, favor clarity and explicitness.

// features/auth/login.component.ts (snippet)
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from './auth.service';

@Component({
  standalone: true,
  template: `
    <form (ngSubmit)="login()">
      <!-- form fields -->
      <button type="submit">Sign in</button>
    </form>
  `,
})
export class LoginComponent {
  private router = inject(Router);
  private route = inject(ActivatedRoute);
  private auth = inject(AuthService);

  async login() {
    await this.auth.signIn();
    const redirect = this.route.snapshot.queryParamMap.get('redirect') || '/dashboard';
    this.router.navigateByUrl(redirect, { replaceUrl: true, state: { from: 'login' } });
  }
}
  • navigate and navigateByUrl are both fine; navigate works with command arrays and relative routes.

  • replaceUrl avoids filling history with transient steps like login.

Route guards: protect, match, and confirm

Functional guards are terse, testable, and DI-friendly. Use canActivate for already-loaded routes and canMatch to gate access before lazy-loading. Use canDeactivate to protect against losing changes.

// app/auth/guards.ts
import { inject } from '@angular/core';
import { CanActivateFn, CanDeactivateFn, CanMatchFn, Router, UrlSegment, Route } from '@angular/router';
import { AuthService } from './auth.service';

export const authCanActivate: CanActivateFn = (_route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  if (auth.isLoggedIn()) return true;
  // Return a UrlTree to redirect without throwing
  return router.createUrlTree(['/login'], { queryParams: { redirect: state.url } });
};

export const authCanMatch: CanMatchFn = (route: Route, segments: UrlSegment[]) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  if (auth.isLoggedIn()) return true;

  const attempted = '/' + segments.map(s => s.path).join('/');
  return router.createUrlTree(['/login'], { queryParams: { redirect: attempted } });
};

export const unsavedCanDeactivate: CanDeactivateFn<{ hasUnsavedChanges(): boolean }> = (component) => {
  return component.hasUnsavedChanges()
    ? confirm('You have unsaved changes. Leave this page?')
    : true;
};

Notes:

  • Returning a UrlTree is the most ergonomic way to redirect from guards.

  • Prefer canMatch for lazy-loaded feature entries. It prevents loading code the user can’t access.

Async redirects in Angular 20

Redirects are often conditional and data-driven. Angular 20 refines ergonomics for async redirects by allowing redirect functions to return a Promise or Observable of a UrlTree, keeping logic declarative in the route table. When you need Dependency Injection, the function executes in a proper injection context.

// app/app.routes.ts (excerpt showing async redirect)
import { Router, Routes } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth/auth.service';

export const routes: Routes = [
  // ...
  {
    path: 'start',
    // Async redirect based on login status
    redirectTo: async () => {
      const auth = inject(AuthService);
      const router = inject(Router);
      const isLoggedIn = await auth.isLoggedInOnce(); // e.g., resolves after token refresh
      return isLoggedIn
        ? router.createUrlTree(['/dashboard'])
        : router.createUrlTree(['/login'], { queryParams: { redirect: '/start' } });
    },
    pathMatch: 'full',
  },
  // ...
];

If your team prefers explicit control (or for compatibility), use canMatch to perform the same async decision and return a UrlTree. Both approaches keep “redirect intent” within the routing layer, preserving a clear separation from UI.

Handling 404s and fallbacks

Keep a final catch-all route at the end. Make the not-found component tiny and isolated.

// shared/not-found/not-found.component.ts
import { Component } from '@angular/core';

@Component({
  standalone: true,
  template: `
    <h2>Page not found</h2>
    <p>The page you’re looking for doesn’t exist.</p>
    <a routerLink="/home">Go to Home</a>
  `,
})
export class NotFoundComponent {}

Developer experience tips that scale

  • Title and data: Use the route title for sensible defaults. Keep route data small and serializable.

  • Preloading: withPreloading(PreloadAllModules) improves perceived speed after the first screen.

  • Guard reuse: Share a single predicate behind multiple guard types (e.g., canActivate and canMatch) to avoid drift.

  • UrlTree over side effects: Prefer returning UrlTree over imperative router.navigate inside guards.

  • Relative navigation: Favor relativeTo where possible to avoid brittle absolute paths.

  • Observability: Listen to router.events for tracing NavigationStart, NavigationEnd, NavigationCancel when debugging.

// Simple router event logging helper (dev only)
import { filter } from 'rxjs';
import { Router, NavigationStart, NavigationEnd, NavigationCancel } from '@angular/router';

export function logNavigation(router: Router) {
  router.events
    .pipe(filter(e => e instanceof NavigationStart || e instanceof NavigationEnd || e instanceof NavigationCancel))
    .subscribe(e => console.debug('[router]', e));
}

Common pitfalls and guardrails

  • Infinite redirect loops: Always check target vs current URL when redirecting conditionally. Returning the same UrlTree repeatedly will stall navigation.

  • Missing pathMatch: For empty-path redirects, pathMatch: 'full' prevents partial matches from hijacking nested routes.

  • Eager vs lazy: Use canMatch for feature entries to avoid loading code for unauthorized users.

  • Query params and state: Preserve user intent via redirect query params. Clear them after successful navigation if they’re transient.

Putting it together: a cohesive flow

  • Users hitting /start are asynchronously redirected based on auth state using async redirects.

  • Unauthenticated users attempting /projects are intercepted by canMatch and sent to /login with a redirect back.

  • Settings protects against accidental loss via canDeactivate.

  • Dashboard and Projects load lazily, preloaded after the first screen.

This balances UX and performance while keeping the routing layer the single source of truth for navigation logic.

Conclusion

A well-structured router is more than a URL switchboard, it’s an agreement about flow, access, and intent. Angular’s modern APIs make routing readable and testable: standalone routes, functional route guards, lazy-loading, and async redirects that keep decisions close to configuration. When your code communicates clearly, your team moves faster and makes fewer mistakes.

Next Steps

  • Extract guard predicates into pure functions and unit test them with dependency mocks.

  • Add a data resolver where you need the route to wait for critical data before rendering.

  • Introduce a prefetch strategy for specific features, or preload on hover using quicklink-style heuristics.

  • Explore view transitions with router integration to smooth screen changes.

  • Audit routes for consistent titles, data contracts, and error handling paths.

]]>
<![CDATA[Data Binding in Angular: Templates, Interpolation, and Property/Event Binding]]> https://blog.lunatech.com/posts/2025-10-15-data-binding-in-angular-templates-interpolation-and-propertyevent-binding/ https://blog.lunatech.com/posts/2025-10-15-data-binding-in-angular-templates-interpolation-and-propertyevent-binding/ Wed, 15 Oct 2025 00:00:00 +0000

Data binding is the heartbeat of Angular templates. Learn how data flows between components and templates using interpolation, property binding, event binding, and two-way binding. This topic also introduces new Angular 20 template features like template literals. We’ll keep the focus practical: clear patterns, minimal surprises, and maintainable code that scales.

The mental model: unidirectional data flow, well-timed feedback

  • From component to view: interpolation and property binding render state.

  • From view to component: event binding captures user intent.

  • Two-way binding is a convenience that composes the two, not a separate mechanism.

Favor predictable one-way flows; reach for two-way when it improves clarity without hiding complexity.

Interpolation: simple, readable, one-way display

Interpolation renders values into text nodes using template syntax like }. It’s ideal for human-readable content.

<h2>{{ title }}</h2>
<p>Welcome, {{ user?.name ?? 'Guest' }}.</p>
<p>Subtotal: {{ price | currency: 'USD' }}</p>

Guidelines:

  • Keep expressions simple. Avoid calling methods that do non-trivial work.

  • Use pipes for presentation logic (formatting, slicing, etc.).

  • Use the safe-navigation operator (?.) to avoid null/undefined errors at render time.

Angular 20 template literals in expressions

Angular 20 enables JavaScript-style template literals inside template expressions for more expressive string composition.

<h2>{{ `${greeting}, ${user?.firstName || 'friend'}!` }}</h2>
<button [attr.aria-label]="`Open details for ${item.title}`">Open</button>

Use them for readable, localized strings or ARIA attributes. Keep them concise. If you’re building complex messages, consider a dedicated formatter or i18n.

Property binding: write to DOM and component inputs

Property binding uses square brackets to set DOM properties, ARIA attributes, styles, classes, and child component inputs.

<!-- DOM properties -->
<input [value]="query()" [disabled]="isSubmitting()" />

<!-- Attributes and accessibility -->
<button [attr.aria-pressed]="isActive" [attr.data-id]="item.id">Toggle</button>

<!-- Classes and styles -->
<div [class.active]="isActive" [style.width.px]="panelWidth"></div>

<!-- Child component inputs -->
<app-avatar [src]="user.photoUrl" [alt]="user.name" [size]="64"></app-avatar>

Under the hood, Angular binds to the actual property when available (safer and more correct than raw attributes). Use [attr.*] only when there is no matching property.

Signals play nicely with bindings

Signals provide push-based reactivity. Call them in templates to read their current value.

import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-search',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <label>
      Query:
      <input [value]="query()" (input)="onInput($event)" placeholder="Type to search..." />
    </label>

    @if (filtered().length === 0) {
      <p>No results</p>
    } @else {
      <ul>
        @for (item of filtered(); track item) {
          <li>{{ item }}</li>
        }
      </ul>
    }
  `
})
export class SearchComponent {
  readonly items = signal(['apple', 'banana', 'citrus', 'date', 'elderberry']);
  readonly query = signal('');
  readonly filtered = computed(() =>
    this.items().filter(v => v.toLowerCase().includes(this.query().toLowerCase()))
  );

  onInput(event: Event) {
    const input = event.target as HTMLInputElement;
    this.query.set(input.value);
  }
}

Note:

  • Property binding [value]="query()" reads the signal.

  • Event binding (input)="..." updates the signal.

  • Built-in control flow (@if and @for) keeps the template declarative and fast.

Event binding: user intent back to the component

Event binding uses parentheses to listen to DOM or component events.

<button (click)="toggle()">{{ isOpen ? 'Close' : 'Open' }}</button>
<input (keydown.enter)="submit()" />
<input (input)="onInput($event)" />

Type your handlers for safety and clarity.

toggle(): void {
  this.isOpen = !this.isOpen;
}

submit(): void {
  if (!this.formValid) return;
  this.save();
}

onInput(event: Event): void {
  const target = event.target as HTMLInputElement;
  this.value = target.value;
}
  • Prefer specific event names like keydown.enter for intent-revealing handlers.

  • Avoid expensive work inside template expressions; do it in component methods or computed signals.

Two-way binding: when it helps, use it intentionally

Two-way binding composes property binding and event binding into a single banana-in-a-box syntax.

With forms (ngModel)

This is convenient in simple forms. Import FormsModule in standalone components.

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-preferences',
  standalone: true,
  imports: [FormsModule],
  template: `
    <label>Nickname: <input [(ngModel)]="nickname" /></label>
    <label>
      <input type="checkbox" [(ngModel)]="emailOptIn" />
      Email me updates
    </label>
    <p>Preview: {{ nickname }} (opt-in: {{ emailOptIn }})</p>
  `
})
export class PreferencesComponent {
  nickname = '';
  emailOptIn = true;
}

With signals, bridge two-way binding by pairing [ngModel] and (ngModelChange):

<input [ngModel]="query()" (ngModelChange)="query.set($event)" />

Custom two-way binding for child components

Define a pair of Input and Output with the Change suffix. Angular will recognize [(value)].

import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-rating',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="stars">
      <button *ngFor="let s of [1,2,3,4,5]"
              type="button"
              [class.filled]="s <= value"
              (click)="setValue(s)"
              [attr.aria-label]="'Set rating to ' + s">
        ★
      </button>
    </div>
  `,
  styles: [`.filled { color: #f59e0b; } button { background:none; border:none; font-size:1.25rem; cursor:pointer; }`]
})
export class RatingComponent {
  @Input() value = 0;
  @Output() valueChange = new EventEmitter<number>();

  setValue(v: number) {
    this.value = v;
    this.valueChange.emit(v);
  }
}

Parent usage:

<app-rating [(value)]="product.rating"></app-rating>
<p>Current rating: {{ product.rating }}</p>

Tip: If your project uses the signal-based inputs API, the model() helper can reduce boilerplate. The classic pair above remains broadly compatible and explicit.

Template syntax patterns that scale

  • Keep expressions pure and cheap. Avoid calling functions that allocate arrays or perform filtering on each change detection; use computed signals or memoized selectors.

  • Bind to properties, not attributes. Prefer [value], [disabled], [checked] over [attr.value], except when no property exists.

  • Use track with @for to avoid re-rendering stable items: @for (item of items; track item.id)

  • For [innerHTML], sanitize or trust only safe content. Angular’s DomSanitizer exists for exceptional cases—prefer plain text whenever possible.

Angular 20 template literal examples in context

Use template literals where they improve clarity, especially for ARIA and labeling.

<!-- Readable, localized-friendly labels -->
<button
  type="button"
  [attr.aria-label]="`${isPlaying ? 'Pause' : 'Play'} ${track.title}`"
  (click)="togglePlay()">
  {{ isPlaying ? 'Pause' : 'Play' }}
</button>

<!-- Combining with pipes -->
<p>{{ `Hello, ${user?.name || 'Guest'}` | titlecase }}</p>

<!-- Attributes that aren’t DOM properties -->
<a [attr.data-test-id]="`link-${link.id}`" [href]="link.url">{{ link.title }}</a>

Keep logic minimal; push complex decisions into the component or computed signals for testability.

A cohesive example: binding the pieces

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RatingComponent } from './rating.component';

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CommonModule, FormsModule, RatingComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article class="card">
      <header>
        <h3>{{ product().name }}</h3>
        <p class="price">{{ product().price | currency:'USD' }}</p>
      </header>

      <label [attr.aria-label]="`Quantity for ${product().name}`">
        Qty:
        <input type="number"
               min="1"
               [ngModel]="qty()"
               (ngModelChange)="qty.set($event)" />
      </label>

      <app-rating [(value)]="rating"></app-rating>

      <p>{{ description }}</p>

      <button type="button"
              class="add"
              [disabled]="isSubmitting()"
              (click)="addToCart()">
        {{ isSubmitting() ? 'Adding…' : 'Add to cart' }}
      </button>
    </article>
  `,
  styles: [`.card{border:1px solid #e5e7eb;border-radius:.5rem;padding:1rem}.price{color:#374151}.add{margin-top:.75rem}`]
})
export class ProductCardComponent {
  readonly product = signal({ id: 1, name: 'Noise-canceling Headphones', price: 199.99 });
  readonly qty = signal(1);
  rating = 4; // two-way bound to child
  readonly isSubmitting = signal(false);

  get description(): string {
    return `${this.product().name} — rated ${this.rating}/5 by our customers.`;
  }

  async addToCart() {
    this.isSubmitting.set(true);
    try {
      await fakeHttpPost({
        id: this.product().id,
        qty: this.qty(),
        rating: this.rating
      });
    } finally {
      this.isSubmitting.set(false);
    }
  }
}

function fakeHttpPost(payload: unknown) {
  return new Promise<void>(r => setTimeout(r, 500));
}

What you see:

  • Interpolation renders human-readable fields.

  • Property binding controls input state and disabled state.

  • Event binding processes clicks and model changes.

  • Two-way binding streamlines rating updates.

  • Template literals keep labels clear and accessible.

Conclusion

Data binding is how intent flows through your Angular app. Interpolation and property binding project state. Event binding captures change. Two-way binding composes both when it reduces friction. With Angular 20’s template literal support, you can craft clearer strings and ARIA labels without sacrificing readability. Keep expressions simple, push complexity into components or computed signals, and lean on built-in control flow for performance.

Next Steps

  • Audit a component for heavy template expressions. Move logic into computed signals or pure methods.

  • Add ARIA attributes with property binding and template literals to improve accessibility.

  • Refactor a custom control to support [(value)] using the input/output pair.

  • Explore Angular’s built-in control flow (@if, @for, @switch) to reduce directive boilerplate.

  • If your team uses signals broadly, standardize patterns for bridging with + (ngModelChange) for crisp two-way behavior.

]]>
<![CDATA[Getting Started with Angular 19: Your First Signals-Powered App]]> https://blog.lunatech.com/posts/2025-10-12-getting-started-with-angular-19-your-first-signals-powered-app/ https://blog.lunatech.com/posts/2025-10-12-getting-started-with-angular-19-your-first-signals-powered-app/ Sun, 12 Oct 2025 00:00:00 +0000

Angular 19 makes signals a first-class, ergonomic way to manage reactive state—without the ceremony many of us associate with using RxJS for every problem. In this guide, you’ll build a small, production-quality feature using signals, computed values, and effects, sticking to Angular basics while practicing patterns you’ll reuse in real apps. Think of it as your “hello world” for signals, with enough structure to scale.

Why signals for your first Angular 19 app

Signals shine when:

  • You want local or feature-level state that’s easy to understand and test.

  • You prefer declarative data flow with minimal boilerplate.

  • You care about performance and predictable change detection.

Signals give you:

  • signal(): a mutable value you set and read like a variable.

  • computed(): a derived value that automatically recalculates when dependencies change.

  • effect(): a side effect that runs when its dependent signals change (think persistence, logging, analytics).

With the built-in control flow (@if, @for, @switch), Angular templates read signals naturally and concisely—no async pipe or manual subscriptions required.

What we’re building

A tiny yet complete “Tasks” feature:

  • Add tasks, toggle done, filter by status.

  • Persist to localStorage.

  • Derive stats via computed signals.

We’ll use:

  • Standalone components.

  • inject() for dependency injection.

  • Signals for reactive state.

  • New template control flow.

If you’re comfortable with Angular basics (components, templates, DI), you’re good to go.

App structure

  • TaskStore: a signals-powered service for state and business logic.

  • AppComponent: a standalone component that renders the UI and interacts with the store.

  • main.ts: bootstraps the app.

TaskStore: reactive state with signals

We’ll keep domain logic, mutations, and persistence here—a clean separation from the component.

import { Injectable, computed, effect, signal } from '@angular/core';

export type TaskFilter = 'all' | 'open' | 'done';

export interface Task {
  id: string;
  title: string;
  done: boolean;
}

function loadTasks(): Task[] {
  try {
    const raw = localStorage.getItem('tasks.v1');
    return raw ? JSON.parse(raw) as Task[] : [];
  } catch {
    return [];
  }
}

@Injectable({ providedIn: 'root' })
export class TaskStore {
  // Source signals
  private readonly tasks = signal<Task[]>(loadTasks());
  readonly filter = signal<TaskFilter>('all');

  // Derived reactive state
  readonly stats = computed(() => {
    const list = this.tasks();
    const done = list.filter(t => t.done).length;
    const open = list.length - done;
    return { total: list.length, open, done };
  });

  readonly filtered = computed(() => {
    const f = this.filter();
    const list = this.tasks();
    if (f === 'open') return list.filter(t => !t.done);
    if (f === 'done') return list.filter(t => t.done);
    return list;
  });

  // Persist tasks whenever they change
  private readonly persist = effect(() => {
    const current = this.tasks();
    localStorage.setItem('tasks.v1', JSON.stringify(current));
  });

  add(title: string) {
    const clean = title.trim();
    if (!clean) return;
    const newTask: Task = { id: crypto.randomUUID(), title: clean, done: false };
    this.tasks.update(list => [newTask, ...list]);
  }

  toggle(id: string) {
    this.tasks.update(list =>
      list.map(t => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }

  remove(id: string) {
    this.tasks.update(list => list.filter(t => t.id !== id));
  }

  clearDone() {
    this.tasks.update(list => list.filter(t => !t.done));
  }

  // Expose readonly getters where helpful
  get all() { return this.tasks.asReadonly(); }
}

Notes:

  • Keep tasks private, expose asReadonly() if needed, and route all mutations through methods. That protects invariants and makes tests straightforward.

  • computed() caches until dependencies change, so stats and filtered are cheap to read in templates.

  • effect() is for side effects only. Don’t derive data there.

AppComponent: template-first with control flow

The component stays lean: read signals, call store methods, and keep the markup honest. No subscriptions and no manual change detection.

import { Component, inject } from '@angular/core';
import { TaskStore, TaskFilter } from './task.store';

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
})
export class AppComponent {
  readonly store = inject(TaskStore);

  setFilter(f: TaskFilter) {
    this.store.filter.set(f);
  }

  add(input: HTMLInputElement) {
    this.store.add(input.value);
    input.value = '';
    input.focus();
  }
}
<header class="app-header">
  <h1>Tasks (Signals)</h1>
  <p>
    Total: {{ store.stats().total }}
    • Open: {{ store.stats().open }}
    • Done: {{ store.stats().done }}
  </p>
</header>

<section class="task-create">
  <input
    #title
    type="text"
    placeholder="Add a task and press Enter"
    (keyup.enter)="add(title)"
    aria-label="New task title" />
  <button (click)="add(title)">Add</button>
</section>

<nav class="filters">
  <button (click)="setFilter('all')" [class.active]="store.filter() === 'all'">All</button>
  <button (click)="setFilter('open')" [class.active]="store.filter() === 'open'">Open</button>
  <button (click)="setFilter('done')" [class.active]="store.filter() === 'done'">Done</button>
</nav>

<section class="task-list">
  @if (store.filtered().length === 0) {
    <p class="empty">No tasks to show.</p>
  } @else {
    <ul>
      @for (task of store.filtered(); track task.id) {
        <li>
          <label>
            <input type="checkbox" [checked]="task.done" (change)="store.toggle(task.id)" />
            <span [class.done]="task.done">{{ task.title }}</span>
          </label>
          <button class="remove" (click)="store.remove(task.id)" aria-label="Remove task">✕</button>
        </li>
      }
    </ul>
  }
</section>

<footer class="actions">
  <button (click)="store.clearDone()" [disabled]="store.stats().done === 0">
    Clear completed
  </button>
</footer>

A few things to notice:

  • Reading a signal in a template uses function-call syntax: store.stats().

  • The new @if and @for syntax is concise and fast; track by a stable id to minimize DOM churn.

  • We avoid ngModel here to keep the example lean; use reactive forms if you need validation and composition.

Bootstrap

With standalone components, the bootstrap is pleasantly thin.

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';

bootstrapApplication(AppComponent).catch(err => console.error(err));

That’s it. No NgModule, no extra ceremony. In a larger app, you’d layer in provideRouter, HTTP, and other providers here.

Developer experience and design notes

From real projects migrating to signals and the new control flow, a few principles keep teams productive:

  • Keep mutations small and intention-revealing.

  • add, toggle, remove, clearDone communicate behavior explicitly.

  • Encapsulate shape and invariants in your store; keep the component mostly declarative.

  • Use computed for any value you want to “feel like” state in the template.

  • stats and filtered are cheap to read and always consistent.

  • Avoid doing ad-hoc filtering in templates; it’s harder to optimize and test.

  • Reserve effect for I/O and cross-cutting behavior.

  • Persistence, analytics, message bus publishing—great uses of effect.

  • Don’t compute UI data inside effect; that’s what computed is for.

  • Prefer signals for local/feature reactive state, embrace RxJS where it fits.

  • Signals are excellent for UI state and domain mutations.

  • Streams still shine for event composition, websockets, and complex async flows.

  • Interop utilities exist if you need to bridge; start simple.

  • Track by stable ids in @for.

  • You’ll avoid unnecessary re-renders and improve perceived performance.

  • Keep templates dumb.

  • Let the store manage logic; your templates will stay readable, and testing gets easier.

Testing the store

Signals play nicely with unit tests because there’s no hidden subscription machinery to manage.

  • Instantiate TaskStore directly, call methods, and assert on taskStore.filtered(), taskStore.stats(), etc.

  • If an effect writes to localStorage, consider injecting a light persistence adapter so you can stub it in tests. For this article’s simplicity, we wrote to localStorage directly; in production, prefer an injected storage port.

Where this scales

This pattern scales surprisingly far:

  • Add tags or due dates? Extend Task and update computed accordingly.

  • Need a multi-page app? Provide routes and lazy-load feature components while keeping a small, focused store per feature.

  • Want undo/redo? Wrap mutations to capture patches and provide intent-level commands.

Signals help you move faster because the mental model is simple: read values, change values, derive values. It’s the right default for many UI flows.

Common pitfalls to avoid

  • Overusing effect for data derivation. If you find yourself setting signals inside an effect just to compute something, reach for computed instead.

  • Mixing many mutable signals in components. Prefer a single cohesive store per feature or sub-feature.

  • Forgetting to track by id in @for. It’s a small habit with big performance wins.

Conclusion

Getting started with Angular 19 and signals doesn’t require a framework rewrite. By leaning on a simple TaskStore and a lean component using the new control flow, we built a small but complete feature with clear reactive state and minimal boilerplate. This is the kind of foundation that keeps teams sane as apps grow—explicit mutations, derived state where it belongs, and templates that read like a story.

Next Steps

  • Add a search signal and a computed that combines filter + search.

  • Extract persistence into an injected storage service and mock it in unit tests.

  • Introduce provideRouter and split the UI into feature routes.

  • Integrate reactive forms for validation on create/edit flows.

  • Explore interop with RxJS for server events or HTTP polling, using signals at the edges.

  • Measure with Angular DevTools and keep track-by rules tight as lists grow.

]]>
<![CDATA[Terminal-first Mouseless Development Or How To Be A Hipster Engineer]]> https://blog.lunatech.com/posts/2025-07-11-terminal-first-mouseless-development-or-how-to-be-hipster-engineer/ https://blog.lunatech.com/posts/2025-07-11-terminal-first-mouseless-development-or-how-to-be-hipster-engineer/ Fri, 11 Jul 2025 00:00:00 +0000

In this article, I'm going to give you a slightly different view on something you do on a daily basis. A philosophy that encourages you to only use what you really need, keeps you away from being distracted, lets you think about what your problem really involves, and trust what your fingers have learned instead of relying on visual representation. That is what I call terminal-first mouseless development. I'll try to sell it to you by giving an overview of how you can benefit from it in real life. Apart from the theory, we are also going to see real applications of these approaches. In particular, we will talk about tmux and neovim — industry standards for becoming a real hipster developer.

Hipster development?

We are all familiar with our IDEs, coupled with other tools for tasks like database management (DBeaver), testing APIs (Postman), and container management (Docker Desktop). Even though they provide an extensive GUI, they are also quite rich in distracting elements. Additionally, using multiple tools and navigating between them means a lot of context switching as well as having quite a loaded environment. If you say that your favorite IDE has everything built-in, then it violates the philosophy of Unix, which says that it is more idiomatic to use small tools that do one thing well rather than the opposite. Sort of a single responsibility principle.

And here comes what I call "**Terminal-first development**", but it can be called by any other similar terms. Its core principle is that your terminal should be your central hub for development tasks. Instead of installing a new GUI application to solve a problem, the terminal-first approach challenges you to:

  • Decompose the problem into smaller, distinct steps.

  • Assign a small, dedicated CLI tool to each step.

  • Combine these tools, piping their outputs together, to solve the initial challenge.

crazy-terminal.gif

As a practical example, you want to select an arbitrary table from your database, get all of the data within it, and pretty-print it as JSON. You have two options:

  1. Install and use a couple hundred megabytes of "pgAdmin" that will create another distractive window in your workflow and eat your memory, blasting your brain with all the buttons and menus around its GUI.

  2. Create a simple script that uses "psql" to get a list of all tables in your database, pass them to "fzf" so you could interactively select them, pass the table name again to "psql" to output data from it in JSON, and finally pass it to "jq" to pretty-print the result.

Even though I have already intentionally hated on option 1, there could be a point like: "`Why would I do everything said in point 2 if I can just go to pgAdmin and press a single button or two?`". And that is valid. And here we come to one of the most important points: the approach described in option 2 is just an example of a philosophy that you can follow or not. And that is your choice. And it would not be incorrect or make you a bad developer. It is all about what you prefer (and how lazy you are :P). But if you selected option 2, then you probably prioritize:

  1. Modularity & Portability

  2. Better resource usage

  3. Minimalistic & distraction-free workflow

  4. Scriptability & Automation

  5. Customisation

Another interesting thing to think about the second approach is that if you look closely, it is all about piping, or basically passing data from one function to another. And with this approach, it gives you a grounded look at something fundamental regarding software engineering in general — it is quite a lot, if not completely, about viewing, manipulating, and creating data. Actually, pretty much what our brains do.

Last but not least — such an approach really encourages learning, deeper understanding, and mastering of general software engineering skills, which eventually raises the level of craftsmanship. Personally, this philosophy is what sparks joy in my everyday work.

Another philosophy that usually goes hand in hand with the terminal-first one is mouseless or keyboard-centric development, which literally means what it says — it encourages you to prioritize the usage of a keyboard over a mouse or trackpad for writing or navigating through your code. It is important to say here that it doesn’t mean that you should never use those. Sometimes it is quite inefficient to avoid using your trackpad, but for the most part, the theory is that mostly using your keyboard makes your development faster, more efficient, and less tiring.

monkey-smashing-mouse.gif

Why? Well, you keep your hands on the keyboard and avoid switching between it and a mouse. Additionally, you rely on muscle memory in the form of keybindings and not on visual navigation to perform actions. This way you reduce mental overhead, stay in the flow state, and perform basic actions much faster. For example, the research by SuperHuman showed that some basic operations that we perform every day can be done from 2 to 5 seconds faster if performed with a keyboard rather than a mouse. And we perform those actions a lot, so think about the amount of time you could save in a day.

But let's not idealize things and talk about the downsides. The primary one is a steep learning curve. From my experience, following those philosophies really makes you rethink the way you approach software development. I struggle to point out the exact points, but it just feels quite different, and you really need time to get used to that new reality, and that basically means a long learning curve. The basic parts of it are memorizing all the keybinds or switching your mindset to use CLIs over graphical applications. But hey, learning all the buttons in your IDE also took time, so it is more about whether you are ready to commit to that.

Core In Practice

So now, let’s finally go from something totally metaphorical to something more practical. There isn't a single right way to implement the philosophy that I described above. But, while finding my own way there, I could distinguish two core elements or, in fact, pieces of software, that will help you to build up a foundation.

tmux

I mentioned that with terminal-first development, your terminal becomes the "central hub" for solving software engineering tasks. When you think about a hub, you probably expect it to provide an infrastructure that you can utilize to effectively achieve your goals. The industry standard for that is called "tmux", which is a terminal multiplexer by its definition.

It allows you to conveniently create terminal windows, splits, or even sessions for grouping. That makes it easy to organize your work between multiple projects, for example, and allows you to navigate more smoothly.

You can say, “Yeah, but my terminal emulator can do the same.” Sure, but what if the keybinds change? What if you switch to another emulator? What if you now have to use a different system? You basically need to adapt and configure this new tool for yourself. So how does using "tmux" help you? It is completely platform and terminal-emulator agnostic. Everything you have to do is to put your configuration file in the root folder and run "tmux". This way you are completely independent of the platform that you run "tmux" on top of.

tmux.png

Another thing is that, at its core, "tmux" is a server, having all your terminal sessions working in the background. So this can at least save you from accidentally pressing Command + Q in your terminal and crashing out again, but what you can also do is to basically have your whole terminal session setup running, that you can SSH from any other machine and have it all there, as "tmux" itself is just a command-line tool. Combining a configuration basis and server nature, you basically become independent of a machine and/or terminal emulation tools, if you have your "tmux" server hosted somewhere.

Regarding the config, you can do quite a lot, starting from setting basic keybinds, finishing with writing custom scripts for your workflow or modifying the UI. There is even a whole ecosystem of plugins!

And if we talk about downsides, there are not that many, except for the steep learning curve, but trust me, the outcome is worth it.

neovim

I think most of you know "vi" — the editor that's impossible to exit. One of the first text editors in existence, it relies on the keyboard only. This is because there was no mouse in the early computer days. Theoretically, you can use it to perform any tasks related to text editing and code writing. But the problem with the original "vi" is that it is as plain as possible, and when it comes to modern development, not really efficient. For example, not having the ability to autocomplete code or quickly navigate to a class definition doesn’t sound like a lot of productivity.

To solve this issue, "vim" was created — a feature-rich version of the original "vi" with things like syntax highlighting, the ability to split windows, etc. And most importantly — it provides the ability for extensive configuration, even featuring its own language — "vimscript". That basically created the possibility to write plugins that allow you to customize your experience in "vim" however you want. As a result, the "vim" plugin ecosystem is probably one of the biggest plugin ecosystems in the world.

But this was not enough for people who considered themselves to be ultra-hipsters. This led to the creation of "Neovim" - a fork, partly rewritten in Lua. This way, significant gains were achieved in terms of extensibility and architecture. Nowadays, "Neovim" is known for its great documentation and is supported by a quite big and active community of contributors.

Ultimately, you can think of "*vim" as a constructor. Its ecosystem provides you with the bricks you can use to build a development tool to satisfy any of your needs. From the most plain text editor to an ultra-feature-rich IDE. Basically, you can completely replace whatever you are using now. Just watch out so as not to violate the Unix philosophy.

So how does switching to "*vim" feel, and what does it bring to your life? First of all, text editing starts to feel so much smoother, and the whole navigation process around the code feels really fluent. Using "*vim" really proves the benefits of trusting your muscle memory via keybinds instead of visual navigating. The overall overhead goes down, and you can also feel it when you have to work with several projects/directories. Opening a project, quickly looking for something, and editing it feels so light and easy. Using "*vim" is like dropping a huge backpack when going uphill and changing it for something small, compact, accessible, but extendable at the same time. And last but not least, making the editor behave literally however you want it to in a programmatic way is another amazing part.

nvim.png

But let’s not forget about the struggles you may face: "*vim" really makes you rethink the way you write your code (and using keybinds is not the only part), which will take quite some time. Another thing is configuring the thing to meet your needs. Yeah, that takes time. Initially, it took me maybe like 20+ hours, and it is also a non-stop process, but that is a fair trade-off for the extensibility you get. There is a joke in the "*vim" community about people spending more time on customizing their config than on actually using it. And another thing is that as it is community-driven, you may face things that don’t work properly. For example, in order to have all the IDE features for Java, you need to run Eclipse’s "jdtls", a language server, which doesn't usually perform well on a large Java codebase. But your mileage may vary.

Conclusions

My main point in this article was to provide you with a new perspective. An approach that you can incorporate in your day-to-day tasks. A philosophy that embraces you to:

  • Think more about what your task involves and what you really need to solve it

  • Maintain your workspace clean and distraction-free

  • Build a unique environment that you love working in

  • Introduce joy and creativity into your routine

And while it might feel like a step back, this philosophy is surprisingly forward-thinking. Many of today's brand-new AI tools are designed specifically for the command line. In the end, there is no single way to do things. Technical benefits like efficiency are important, but so is finding joy and pride in your craft. Hipster engineering is something that makes me a better professional and makes me love what I do. My sincere hope is that you find your own way to do the same. Thanks for reading.

dancing-puppy.gif
]]>
<![CDATA[Ctrl+Alt+Defeat: Noob vs. Neural Net]]> https://blog.lunatech.com/posts/2025-07-04-ctrl-alt-defeat/ https://blog.lunatech.com/posts/2025-07-04-ctrl-alt-defeat/ Fri, 04 Jul 2025 00:00:00 +0000

For decades, competitive games have served as milestones in artificial intelligence research. From IBM’s Deep Blue beating Garry Kasparov at chess in 1997, to AlphaGo’s victory over Lee Sedol in 2016, games have offered a clear stage for AI to measure itself against the best human minds.

But as games have grown more complex, so too has the challenge. Turn-based board games like chess and Go, while difficult, offer complete information and relatively limited options per move. Real-time strategy games like StarCraft II and Dota 2, however, introduce chaos: thousands of actions per minute, imperfect information, shifting alliances, and the need for long-term planning — all in real time.

So the question arises: Can AI beat humans in these games, fairly, without relying on superhuman speed or godlike awareness? Let’s take a deeper look into how two landmark systems, OpenAI Five and AlphaStar, set out to do just that.


The Complexity of Real-Time Strategy Games

To appreciate the achievements of AI in games like Dota 2 and StarCraft II, it's essential to understand what makes these games hard:

  • Partial information: Players don’t see the whole map due to fog of war.
  • High action complexity: At any moment, there are thousands of possible moves.
  • Real-time dynamics: No turns, decisions must be made continuously.
  • Coordination: Especially in team games, success hinges on synergy and communication.
  • Long-term planning: Decisions made in the early game can determine the late-game outcome.

Unlike games with set openings and established endgames, these environments are closer to the messiness of the real world and that’s exactly why they interest AI researchers.

OpenAI Five: Dota 2 Without Superpowers

In 2019, OpenAI introduced OpenAI Five, a team of five neural networks trained to play Dota 2, a popular and highly complex team-based multiplayer game. Unlike previous AIs, OpenAI Five didn’t rely on hardcoded rules. Instead, it learned by playing itself millions of times in a massive distributed training setup.

What made OpenAI Five especially impressive was the effort to simulate human-like constraints:

  • Reaction time was capped at 200 milliseconds, close to average human reflexes.

  • Actions per minute (APM) were restricted to human levels, avoiding the inhuman speed advantage.

  • It had no access to information unavailable to humans, like opponent positions under fog of war.

Despite these constraints, OpenAI Five steadily improved and eventually beat top human teams culminating in a 2–0 victory over the reigning world champion team OG at The International in 2019.

Yet, Five was not without flaws. It was:

  • Weaker in novel situations it hadn’t seen during training.

  • Sometimes inflexible, making odd decisions when opponents deviated from expected tactics.

  • Emotionally agnostic, meaning while it would never play emotionally, it also doesn’t have the ability to read human psychology or use momentum the way humans do.

Still, the fact that an AI could beat the world’s best without using non-human like reactions speeds speaks for itself.

AlphaStar: Outmaneuvering Pros in StarCraft II

DeepMind’s AlphaStar tackled StarCraft II, another legendary RTS known for its brutal learning curve and mechanical demands. It used a combination of imitation learning (watching human games) and self-play reinforcement learning to master the game.

Like OpenAI Five, AlphaStar imposed human-like limitations:

  • Capped APM and reaction delays (averaging around 350 milliseconds).

  • Camera view limitations, meaning it had to "look" around the map like a human player.

  • Trained against a diversity of opponents and strategies.

AlphaStar climbed the European ladder to Grandmaster, ranking in the top 0.2% of players. It beat professional players like TLO and MaNa convincingly, sometimes using unexpected and creative strategies.

However, it too had its shortcomings:

  • Predictability: Once players had time to study it, some human pros found exploitable patterns.

  • Lack of intuitive game sense: Humans often make intuitive calls based on experience, psychology, or a “feel” for the flow of the game — AlphaStar relied solely on data and outcomes.

  • Difficulty adapting to “meta shifts”, since it lacked the kind of quick generalization humans can make from limited examples.

Even so, AlphaStar showed that AI could compete — and win — not by outclicking, but by outthinking.

Summary of the restrictions imposed on the AI's

Metric Pro Human Player OpenAI Five AlphaStar
Average Reaction Time ~200 ms 200 ms 350 ms
Max APM ~300 ~180 ~150
Map Vision Partial Partial Limited camera view
Strategy Adaptation High Medium Medium-High

Where AI Still Falls Short

Even with these victories, AI is not yet a complete replacement for human intelligence in games. Some weaknesses include:

  • Lack of common sense or intuition: AIs still struggle with decisions that require real-world reasoning or emotional awareness.

  • Context blindness: Without extensive training, AIs can’t generalize well to new game versions or unexpected strategies.

  • Communication and deception: While some advanced AIs, like Meta's "CICERO", have made progress in games that involve negotiation and persuasion — such as the board game Diplomacy — most game-playing AIs still struggle to understand or influence human opponents the way skilled players can. They can't read body language, sense bluffing, or adapt their strategy based on trust or psychology — key elements of human gameplay.

  • Creativity with purpose: AIs can discover new tactics, but they don’t “understand” them in a human sense, nor can they justify them conceptually.

In essence, AI can win — but it doesn't always know why it wins.

Conclusion: A New Era of Competitive Play

So, can AI truly outplay humans at complex games? The answer, remarkably, is yes — even under constraints that mimic human limitations. OpenAI Five and AlphaStar both demonstrated that intelligent agents can excel in games long thought too complex for machines.

But while the victories are real, the limitations are too. These AIs don’t think or feel like us. They win through scale, training, and narrow focus — not general intelligence or intuition.

Still, each success in these arenas nudges us closer to AI that can not only perform, but reason, adapt, and collaborate — skills that matter far beyond the game board.

]]>
<![CDATA[GPU Programming For The Brave]]> https://blog.lunatech.com/posts/2025-06-27-gpu-programming-for-the-brave/ https://blog.lunatech.com/posts/2025-06-27-gpu-programming-for-the-brave/ Fri, 27 Jun 2025 00:00:00 +0000

Introduction

GPUs, some might call them graphic cards, have never been a stranger for video gamers. The evolution of GPUs significantly changed not just the video game industry, but also the field of parallel programming.

I was lucky enough to participate some courses that briefly introduced GPU programming during my master study. As my first humble attempt, I hereby write down my knowledge and understanding about GPU programming in this blog post. Hopefully, after reading this write-up, you can have a basic idea about: how GPUs work, how to do some simple GPU programming and why it is so important to the field of AI.

What is GPU

GPU is the abbreviation of "graphics processing unit". It is a specialized electronic circuit designed for digital image processing and to accelerate computer graphics, being present either as a discrete video card or embedded on motherboards, mobile phones, personal computers, workstations, and game consoles.

If you have tried to build your own PC, you will probably call the big gas-stove-look-a-like thing in Figure 1 a GPU. However, this is not entirely correct. Graphics card is a more suitable name for it. And if you have ever had a chance to disassemble a graphics card like me, then I am sure you will notice there are much more than just a GPU on a graphics card. The GPU itself is only a small part on it and there's memory, power supply and cooling unit. The composition resembles any normal PC you can see. Figure 2 is taken when I had an GPU memory overheating issue. As you can see in the picture, the GPU is surrounded by the red box and blue boxes for the GPU memory. The part on the right is the cooling unit.
800
800

The small exercise

There is no way we can actually know how to do GPU programming by just looking at the composition picture. To help with the understanding, let's consider a small code exercise where you have to implement a simple fill_matrix function in C to fill a rectangle shape within a two-dimensional matrix with some certain value:

#include <stdio.h>
#include <stdlib.h>

void fill_matrix(int **s, int x_len, int y_len, int draw_start, int draw_end, int value_to_fill) {
  // IMPLEMENT ME
}

void print_matrix(int **s, int x_len, int y_len) {
  // doesn't matter...
}

int64_t initialize_matrix(int rows, int cols) {
  // doesn't matter...
}

int main() {
  int **s = (int **)initialize_matrix(10, 10);
  printf("before: \n");
  print_matrix(s, 10, 10);

  fill_matrix(s, 10, 10, 2, 8 9);

  printf("after: \n");
  print_matrix(s, 10, 10);
  return 0;
}

C version

Easy, isn't it? All we need to do is to use two nested for-loops to fill the value when the loop arrives the expected range:

void fill_matrix(int **s, int x_len, int y_len, int draw_start, int draw_end, int value_to_fill) {
  for (int i = 0; i < y_len; i++) {
    for (int j = 0; j < x_len; j++) {
      if (i > draw_start && i < draw_end && j > draw_start && j < draw_end) {
        s[i][j] = value_to_fill;
      }
    }
  }
}

If you would like to speed up your implementation, you can even use OpenMp to turn your it into a multi-threaded implementation by simply adding #pragma omp parallel for collapse(2) on top of the outer for-loop. After re-compiling and running export OMP_NUM_THREADS=4, your program should automatically delegate the execution of the for-loop to at most 4 threads.

Now it seems like we really pushed to the boundary, and couldn't get any more speedup unless increasing the number of threads. However, what we have seen so far is still in the realm of CPU programming, where your code gets executed by the CPU. Besides that, the time complexity of the implementation is O(n*m), which is not a very pleasant number. So let's try to make use of the power of GPUs, with which we could achieve O(1) complexity.

CUDA version

TIP

You might find it helpful to temporarily forget what you have learnt about thread and kernel when reading this section.

__global__ void fill_matrix_kernel(int* matrix, int rows, int cols, int draw_start, int draw_end, int value) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;

    if (row > draw_start && row < draw_end && col < draw_end && col > draw_start) {
        int idx = row * cols + col;
        matrix[idx] = value;
    }
}

int main() {
  const int rows = 10;
  const int cols = 10;
  const size_t size = rows * cols * sizeof(int);

  // Host memory
  int* h_matrix = (int *)malloc(size);

  // Device memory
  int* d_matrix;
  cudaMalloc((void **)&d_matrix, size);

  // Define grid and block dimensions
  dim3 block(32, 32);  // 256 threads per block
  dim3 grid(
      (cols + block.x - 1) / block.x,  // ceil(cols/block.x)
      (rows + block.y - 1) / block.y   // ceil(rows/block.y)
  );

  // Launch kernel
  fill_matrix_kernel<<<grid, block>>>(d_matrix, rows, cols, 2, 8 9);

  // Copy result back to host
  cudaMemcpy(h_matrix, d_matrix, size, cudaMemcpyDeviceToHost);

  // Verify values
  print_matrix(h_matrix, rows, cols);

  // Cleanup
  free(h_matrix);
  cudaFree(d_matrix);

  return 0;
}

Above is the CUDA implementation. CUDA is a C-like programming language provided by Nvidia. Naturally, it only runs on Nvidia cards. To compile the code above, we can simply run nvcc -o code_example code_example.cu just like when compiling C code using gcc. Then, the code_example it produces also isn't any different from other native executable, which could be run by the command ./code_example.

So what happens when we run it? Besides allocating memory on the device, which is our graphics card, the computation kernel (the fill_matrix_kernel function) is executed by all the GPU threads that we requested simultaneously. In the example, we define a grid of one block ((10 + 32 - 1) / 32 = 1) with 256 threads on it. GPU threads are fundamentally different from the CPU threads we know. By design, the number of GPU threads on a GPU is much larger than the number of CPU threads on a CPU. On top of that, what is executed by CPU threads is completely dependent on how you program it. On contrast, GPU threads provides high-throughput due to the nature of simultaneous execution for a kernel. Therefore, we need a way to control the behavior of each GPU thread. Luckily, an unique threadId is assigned to each GPU thread within the same block and each block has a unique blockId. What we can do is to see if the the current thread is within the drawing range and fill the value accordingly based on the location of the thread (blockId * number of blocks + threadId) , which is exactly what the if clause is doing.

480
800

If we leave out the main() function, the actual implementation is only 6 lines and there is no loop being used at all. But how much faster it really is? When running with the matrix shape of 32768 * 32768, our CUDA implementation can finish it within 0.3 seconds while the C implementation needs 1.9 seconds.

800

Impressive, isn't it? But trust me, everything seems reasonable when you actually see the difference of thread numbers:

192 threads in total 192 threads in total 21760 CUDA threads
TIP
This code example might be too simple and too boring for you. But if you think of the matrix that we are filling as a screen, and the value as RGB value -- We are actually rendering a screen!

Okay, but why AI?

As you might have heard, GPUs are widely used in the field of AI. Given the high-throughput trait of GPU, the process of AI model training can be significantly facilitated. But why is that?

Look into the AI

Thankfully, Wikipedia made it a lot easier for me to explain AI:

"The largest and most capable LLMs are generative pretrained transformers (GPTs), which are largely used in generative chatbots such as ChatGPT or Gemini."

"A GPT is a type of LLM and a prominent framework for generative artificial intelligence. It is an artificial neural network that is used in natural language processing by machines.".

To put it simply: most of the popular AIs are made of neural networks. A neural network is composite of multiple layers of nodes. The first layer takes input from the outside world, normally as the format of a vector of numbers. The output of a layer consists of the output number from each node, which is calculated by summing the input times the weight of the node (sum(input * weight)). And all the subsequent layers take input from the previous one. The process of training the neural network aims to find the weights for each nodes so that the output is most acceptable. And it requires to feed the input -> calculate the output -> compare with the expected output -> adjust the weights repetitively.

800

We can let GPU run this

If we try to write a simple implementation, or even pseudo code, of how things are done in each layer of a neural network, we could arrive at what is shown in Figure 7 . Once again we see a pattern we have seen just before: a linear algebra calculation wrapped by two for-loops. Therefore, we can easily rewrite to a CUDA implementation shown in Figure 8 .

600
900

Both Figure 7 and Figure 8 are taken from a research article by Ricardo Brito et al[ 1 ]. The authors managed to utilize the high-throughput of GPU to accelerate the training process of a neural network in the year of 2016. Except for the countless open-source repositories that implement CUDA-based neural networks, Nvidia offers cuDNN as a GPU-accelerated library of primitives for deep neural networks. Popular neural network frameworks like PyTorch and TenhsorFlow can operate on GPU devices without any extra effort.

To sum up

GPUs, which are originally made for graphics processing, has shown a huge potential in the field of parallel programming and AI training due to their high-throughput nature. This is achieved by piling significant amount of GPU threads and impose simultaneous execution of the compute kernel. Even though it's not quite possible to assign GPU threads for different execution routine like CPU threads, we can still do minimum control-flow manipulation based on their threadId. Several examples of CUDA, which is a C-like GPU programming language offered by Nvidia, are also shown to demonstrate its syntax.

Finally, you can checkout the code examples I used in this GitHub repo .

References

  • ] Brito R., Fong S., Cho K., Song W., Wong R., Mohammed S., Fiaidhi J.

    "GPU-enabled back-propagation artificial neural network for digit recognition in parallel". The Journal of Supercomputing. 72, (2016). https://doi.org/10.1007/s11227-016-1633-y

]]>
<![CDATA[Graveyard of technologies]]> https://blog.lunatech.com/posts/2025-06-20-graveyard-of-technologies/ https://blog.lunatech.com/posts/2025-06-20-graveyard-of-technologies/ Fri, 20 Jun 2025 00:00:00 +0000

ALGOL, Japronto, and Apache Ant represent fascinating case studies of software that, despite their technical merits, faced significant challenges in achieving lasting market success. Each offers valuable lessons about the complex relationship between technical excellence and commercial viability. ALGOL, Japronto, and Apache Ant: Lessons in Technology Adoption

Represent fascinating case studies of technologies that, despite their technical merits, faced significant challenges in achieving lasting market success. Each offers valuable lessons about the complex relationship between technical excellence and commercial viability.

ALGOL: Revolutionary Syntax That Shaped Programming

ALGOL introduced groundbreaking programming concepts that influenced virtually every modern programming language. The language featured elegant recursive procedures and call-by-name parameters, as demonstrated in this classic example:

real procedure GPS (I, N, Z, V); integer I; real N, Z, V;
begin
   for I := 1 step 1 until N do
      Z := V;
   GPS := 1;
end;

This GPS (General Problem Solver) procedure showcased ALGOL's sophisticated parameter passing mechanisms and recursive capabilities. The language's clean syntax and mathematical precision made it ideal for academic research and algorithm description. ALGOL's influence can be seen in modern languages through its introduction of block structure, lexical scoping, and formal syntax definition using Backus-Naur Form.

However, ALGOL's academic origins became its commercial weakness. The language prioritized theoretical elegance over practical business applications, creating a barrier for commercial adoption. Unlike FORTRAN, which had IBM's backing and clear scientific computing applications, ALGOL remained primarily an academic exercise without strong industry support.

Japronto: The Performance Paradox

Japronto emerged as a high-performance Python HTTP framework, promising extraordinary speed through aggressive optimization. A typical Japronto application demonstrated its streamlined approach:

from japronto import Application

def hello(request):
    return request.Response(text='Hello world!')

app = Application()
app.router.add_route('/', hello)
app.run(debug=True)

The framework achieved remarkable performance by optimizing HTTP pipelining and utilizing the picohttpparser C library with SSE4.2 CPU instructions. Japronto could handle over 1 million requests per second in benchmarks, dramatically outperforming traditional Python frameworks and even some Go alternatives.

However, this performance came at a significant cost. Japronto's speed optimizations discouraged the use of Python-level objects and complex application logic. The framework relied heavily on HTTP pipelining, which modern browsers don't support reliably. Most critically, real-world applications requiring database connections, business logic, and data processing couldn't maintain Japronto's benchmark performance levels.

Apache Ant: The XML Build Tool Era

Apache Ant dominated Java build automation for years with its XML-based configuration system. A typical Ant build file demonstrated its declarative approach:

<project name="MyFirstAntProject" default="compile" basedir=".">
    <property name="src.dir" location="src" />
    <property name="build.dir" location="bin" />

    <target name="clean">
        <delete dir="${build.dir}" />
    </target>

    <target name="compile" depends="clean">
        <javac srcdir="${src.dir}" destdir="${build.dir}" />
    </target>
</project>

Ant succeeded in providing platform-independent build automation and flexible task execution. Its XML-based configuration allowed detailed control over build processes, and its extensible architecture supported custom tasks and complex build workflows.

However, Ant's imperative approach became increasingly cumbersome as projects grew in complexity. The lack of dependency management, standardized project layouts, and convention-over-configuration principles made Ant builds verbose and difficult to maintain. Maven's introduction of automatic dependency resolution and Gradle's programmatic build scripts eventually displaced Ant in most modern Java projects.

Netscape Navigator: The Pioneer That Lost the War

Netscape Navigator pioneered web browsing and helped create the modern internet, but ultimately fell victim to Microsoft's aggressive competitive tactics and strategic missteps. Despite its early dominance, Netscape's market share collapsed in the late 1990s.

Netscape Navigator introduced fundamental web technologies including JavaScript, SSL encryption, and dynamic HTML capabilities. The browser's plugin architecture and standards-based approach laid the foundation for modern web development and demonstrated the potential for rich, interactive web applications.

However, Netscape's downfall illustrates how market leadership can quickly evaporate in fast-moving technology sectors. Microsoft leveraged its Windows monopoly to bundle Internet Explorer with every PC, making it the default browser for millions of users. Netscape also made strategic errors, including focusing too heavily on enterprise solutions while neglecting the consumer market, allowing Microsoft to catch up and eventually surpass Netscape's technical capabilities.

Adobe Flash + ActionScript: A Multimedia Giant's Swift Decline

For over a decade, Adobe Flash was the dominant platform for rich multimedia content on the web. Powered by ActionScript, a scripting language similar to JavaScript, Flash enabled highly interactive websites, games, and animations. A classic snippet of ActionScript might look like:

var myText:TextField = new TextField();
myText.text = "Hello, World!";
myText.x = 100;
myText.y = 100;
addChild(myText);

This interactivity revolutionized early web experiences, making Flash a staple in web development and advertising. ActionScript 3.0 introduced object-oriented features, bringing more structure and power to web applications.

Despite its popularity, Flash faced mounting criticism for its performance, security vulnerabilities, and closed ecosystem. The death knell came when Apple declined to support Flash on iOS, signaling the industry's shift toward open standards like HTML5, CSS3, and JavaScript. Adobe officially ended Flash support in 2020.

Visual Basic: Accessibility Meets Obsolescence

Microsoft’s Visual Basic (VB) democratized Windows software development in the 1990s. Its simple syntax and drag-and-drop interface allowed non-programmers and beginners to create full-fledged Windows applications rapidly:

Module Module1
   Sub Main()
     Console.WriteLine("Hello World!")
   End Sub
End Module

VB's tight integration with the Windows API and rapid application development tools made it a hit for business applications. However, as .NET and more modern languages like C# emerged, Visual Basic was gradually phased out. VB.NET, its successor, attempted modernization but lacked traction among new developers.

The rise of more versatile, cross-platform, and open-source development frameworks ultimately made Visual Basic an outdated choice for contemporary software needs.

Fortran: The Long-Reigning King of Scientific Computing

Developed in the 1950s, Fortran (FORmula TRANslation) was one of the first high-level programming languages. Its strength in numerical computation made it the go-to choice for scientists and engineers for decades. A basic Fortran program might look like:

PROGRAM Hello
   PRINT *, 'Hello, world!'
END PROGRAM Hello

Fortran introduced critical concepts like structured programming and efficient array handling, which made it ideal for high-performance computing tasks such as climate modeling and computational fluid dynamics.

Though still used in legacy scientific codebases, Fortran's relevance has dwindled. Modern languages like Python, with libraries like NumPy and SciPy, offer more flexible and accessible alternatives, leading to Fortran’s slow fade from the mainstream.

Smalltalk: Object-Oriented Pioneer with Limited Reach

Smalltalk was a trailblazer in object-oriented programming. It introduced core concepts such as message passing, live coding environments, and a uniform object model that inspired languages like Java, Python, and Ruby. A Smalltalk code example:

Transcript show: 'Hello, world!'; cr.

The entire environment was built from objects, offering unprecedented dynamism and introspection. Smalltalk's interactive IDE and immediate feedback loop remain unmatched in some respects.

Yet, Smalltalk struggled with adoption due to performance issues, steep learning curves, and limited tooling outside its own ecosystem. While it remains influential in academic and niche circles, it was overshadowed by more pragmatic object-oriented languages that better integrated with mainstream operating systems and development workflows.

Hardware Nightmares: When Innovation Meets Market Reality

Windows Phone, Google Glass, Microsoft Zune, and Netscape Navigator represent fascinating case studies of hardware and platforms that, despite their technical innovations, faced significant challenges in achieving lasting market success. Each offers valuable lessons about the complex relationship between technological capability and commercial viability.

Windows Phone: Microsoft's $7.6 Billion Lesson

Microsoft's Windows Phone stands as one of tech's most expensive failures, with the company writing off $7.6 billion related to the Nokia acquisition. Despite having superior hardware and a polished user interface, Windows Phone never gained meaningful market share.

The platform featured a unique tile-based interface that demonstrated Microsoft's innovative approach to mobile design. This Live Tile system showcased Windows Phone's dynamic interface capabilities and integration with the broader Windows ecosystem. The platform's Metro design language influenced modern UI design principles and demonstrated Microsoft's vision for unified experiences across devices.

However, Windows Phone's failure stemmed from entering the market too late and creating a vicious ecosystem cycle. Microsoft launched Windows Phone 7 in 2010, three years after the iPhone had already transformed the industry. By then, iOS and Android had established dominant positions with hundreds of thousands of apps, while Windows Phone launched with only 2,000. The platform suffered from a destructive cycle where low user adoption meant developers ignored the platform, which in turn meant fewer apps, leading to even lower adoption.

Google Glass: The Wearable That Wasn't Ready for Society

Google Glass generated enormous hype as the future of wearable computing but crashed spectacularly due to privacy concerns and social acceptance issues. Despite Google's technological prowess, the product was discontinued just two years after its 2013 launch.

The Glass platform introduced revolutionary concepts in augmented reality and hands-free computing. Its voice recognition capabilities and heads-up display technology influenced modern AR development and demonstrated the potential for seamless human-computer interaction through voice commands and gesture controls.

However, Google Glass faced a perfect storm of problems that made it unsuitable for mainstream adoption. The $1,500 price tag made it inaccessible to most consumers, while the device's bulky design and visible camera created immediate privacy concerns. People worried about being recorded without consent, leading to bans in restaurants, bars, and other public spaces. Most critically, Google failed to clearly define the target market and value proposition for everyday users.

Microsoft Zune: The iPod Killer That Never Was

Microsoft's Zune music player launched in 2006 as a direct competitor to Apple's iPod but failed to make a significant dent in Apple's market dominance. Despite some innovative features, the Zune became synonymous with Microsoft's inability to compete in consumer electronics.

Zune introduced innovative wireless sharing capabilities and social music discovery features that predated modern streaming services. Its larger screen and improved navigation demonstrated Microsoft's understanding of user interface design, while the Zune software provided a more integrated media management experience than many competitors.

However, the Zune suffered from classic late-mover disadvantages. By 2006, the iPod had already established itself as the dominant music player, with a mature ecosystem including iTunes and strong brand loyalty. Microsoft's device offered improvements like wireless sharing and a larger screen, but these incremental benefits weren't enough to overcome Apple's head start. Microsoft also struggled with marketing and brand positioning, lacking the sleek design aesthetic that made Apple products desirable.

Conclusion: Lessons in Technology Adoption

The histories of these varied technologies illustrate a critical lesson: technical merit alone is insufficient to guarantee market success. Whether it's a programming language like ALGOL that lacked commercial focus, a framework like Japronto whose benchmark performance was impractical for real-world use, or hardware like the Zune and Windows Phone that couldn't overcome established ecosystems, the pattern is consistent.

Sustainable adoption requires a delicate balance of innovation with practical usability, strong industry support, and the ability to evolve alongside changing user needs and market dynamics. The failure to address real-world problems, build a supportive ecosystem, or adapt to new industry standards ultimately led to the decline of these once-promising technologies.

]]>
<![CDATA[HyperLogLog++]]> https://blog.lunatech.com/posts/2025-06-13-hyperloglog/ https://blog.lunatech.com/posts/2025-06-13-hyperloglog/ Fri, 13 Jun 2025 00:00:00 +0000

This document demonstrates a fascinating technique that tackles a complex computational problem using an elegant probabilistic strategy.

The Problem

The count-distinct problem (also known as the cardinality estimation problem) involves finding the number of distinct elements in a data stream containing repeated elements.

Traditionally, solving this problem requires Θ(D) space complexity, where D represents the number of unique elements. This is because we need to store each unique element to determine whether a new element has been previously encountered.

Unfortunately, this approach doesn't scale for massive cardinalities found in real-world applications such as:

  • Unique Query Counting: Tracking the number of unique searches in a search engine

  • Network Monitoring: Counting unique source IP addresses

  • Unique Visitor Tracking: Monitoring distinct users across web platforms

NOTE
In the original paper and related literature, "cardinality" refers to the number of distinct elements.

The Algorithm

When you only need an estimation, or when the number of unique elements makes it impractical to store them all in memory, HyperLogLog++ provides an excellent solution.

This technique estimates the number of unique elements with a relative percentage error between -4% and 6%, using only 16 KB of memory (though this depends on the number of registers configured).

Example

Let me illustrate the core concept with a simple analogy.

Imagine someone spends an entire day rolling a die and tells you the maximum number of consecutive 1s they rolled was 3. While you can't determine the exact number of rolls, you can estimate it by calculating the probability of this event occurring.

The probability of rolling three consecutive 1s is:

1/6 * 1/6 * 1/6 = 1/(6^3)

Therefore, the expected number of trials needed to observe this event is:

1 / (1/6^3) = 6^3 = 216

Translating this concept into an algorithm:

  • The Dice game becomes a Hash function that hashes the element in input and yields bits. These bits represent the outcome of the game run, like if the dice had only two faces, 1 and 0.

  • The event taken into account was that the longest sequence of 1's (that the die returns 1) at the start of the game, but in this case, since hashing returns a binary output, the algorithm will calculate the event of longest run of leading bits set to 0. Technically, it's also possible to count leading ones, but counting leading zeros is a common convention.

  • Probability of rolling a 1 (1/6) → Probability of a bit being 0 (1/2)

  • Number of plays → Cardinality

This represents the fundamental idea behind the Flajolet–Martin algorithm, which HyperLogLog++ improves upon through three key enhancements.Correction Factor is applied to mitigate the overestimation bias inherent in the standard HLL algorithm.Grouped Averaging specifically the harmonic mean across multiple registers (or buckets), to significantly improve the accuracy of its cardinality estimates. HLL++ for small cardinalities suffers from the Overestimation Bias, for that reason Linear Counting is used, which is another probabilistic counting algorithm simple and highly accurate for that scale.

Implementation

Data Structure

class HyperLogLog {
    //Number of registers. Must be a power of 2. `buckets = 2^p`
    private final int buckets = 16384;

    // Correction factor
    private final double alpha = 0.7213 / (1 + 1.079 / buckets); //0.72125250052

    //Initialize the array of 16 383 (2^14-1) elements
    private final byte[] registers = new byte[buckets];

The hash function outputs 64 bits, the first 14 are used to address the registers while the last 50 are used for calculating the ranking. That's why the size of the register array is 16384 and the type is byte sufficient to count up to 50 leading zeros.

Add Operation

The add operation is how you feed individual elements into the HyperLogLog structure. The goal is to update the internal state of the HLL to reflect the presence of this new element.

public void add(String element) {
        // Calculate hash
        long hash = hashElement(element);
        // Take the first 14 bits to address the register
        short registerIndex = getIndex(hash);
        // Take the last 50 bits
        long value = getValue(hash);
        // Calculate the rank value for the target register
        byte rank = (byte) (leadingZeros(value) + 1);
        // Keep the biggest rank between rank and registers[registerIndex]
        if (rank > registers[registerIndex]) {
            registers[registerIndex] = rank;
        }
    }
  1. Calculate a 64-bit hash of the input

  2. Extract the first 14 bits of the hash to index the register

  3. Use the remaining 50 bits to calculate the number of leading zeros

  4. Compare with the existing register value and store the biggest rank

Count Operation

The count operation provides an approximation of the number of distinct elements that have been added to the HyperLogLog.

public long count() {
        double sum = 0;
        int zeroRegisters = 0;
        // Calculate the harmonic mean of the register values
        for (byte register: registers) {
            sum += Math.pow(2, -register);
            if (register == 0) {
                zeroRegisters++;
            }
        }
        // Raw estimate
        double estimate = alpha * buckets * buckets / sum;
        // Apply corrections for small cardinalities
        if (estimate <= 2.5 * m && zeroRegisters > 0) {
            // Linear counting
            estimate = buckets * Math.log((double) buckets / zeroRegisters);
        }
        return Math.round(estimate);
    }
  1. Calculate the harmonic mean of all register values

  2. Apply a correction factor

  3. Count the number of empty registers

  4. Fall back to linear counting for small cardinalities

Merge Operation

The merge operation allows you to combine two or more HyperLogLog structures into a new HLL structure (or update one with another). This is a powerful feature for distributed systems where distinct counts might be computed on subsets of data in parallel and then combined.

public void merge(HyperLogLog that) {
        for (int i = 0; i < buckets; i++) {
            if (this.registers[i] < that.registers[i])
                this.registers[i] = that.registers[i];
        }
    }
  1. Compare each register pair at the same index

  2. Retain the register with the larger value

Relative Percentage Error

Error Plot

In this graph x-axis represents the expected cardinality, and it goes from 0 to 100k elements. While the y-axis represents the relative error in percentage. We can observe that in the first part that linear counting is used for cardinalities up to approximately 40,000, after which HyperLogLog++ takes over, the error is quite high, but it starts to converge around 0 the more elements come in. The next graph shows what would happen without linear counting, the Overestimation Bias.

No Linear Counting

Conclusion

HyperLogLog++ is a good solution for counting unique elements in those use case where storing them in memory is not practical. It provides O(1) complexity both in terms of time and space, and the relative error is in the range of -4% to 6%.

Philippe Flajolet

_Philippe Flajolet - First author of "HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm"_

]]>
<![CDATA[A Quest to Tame Large Language Models]]> https://blog.lunatech.com/posts/2025-05-26-demystify-llms/ https://blog.lunatech.com/posts/2025-05-26-demystify-llms/ Mon, 26 May 2025 00:00:00 +0000

Introduction

Large Language Models represent one of the most significant advances in artificial intelligence, fundamentally transforming how we interact with natural language processing systems. Understanding their architecture and mechanisms is essential for anyone working with modern AI systems.

This guide examines the core concepts underlying LLMs, from foundational statistical models to sophisticated attention mechanisms, providing practical insights for implementation and deployment. We'll explore how these systems generate coherent text, the role of context in language understanding, and the architectural innovations that enable modern capabilities.

If new to the language of LLMs, this guide aims to demystify some of the "magic" surrounding them. If already versed in the language of LLMs, this guide hopes to be a refresher.

1. The Foundation: Probabilistic Text Generation

 900

Large Language Models operate as sophisticated probability machines. At their core, they analyze patterns in text data to predict the most likely next token given a specific context. While they incorporate stochastic elements through temperature controls, pedantically, their underlying mechanisms are fundamentally deterministic—the same prompt with identical parameters will consistently produce the same output. In pratice, however, they are far from determinisitic—the stochastic elements and numerous other caveats render that statement for "illustration purposes only".

Statistical Language Models

Statistical language models form the conceptual foundation for understanding modern LLMs. These models process a _corpus of text and use statistical patterns to predict subsequent words or tokens . While contemporary LLMs have evolved far beyond these simple approaches, understanding statistical models illuminates the core challenges that advanced architectures address.

Bi-gram Models

A bigram model represents the simplest form of statistical language modeling. It analyzes pairs of consecutive words to build frequency tables that inform predictions.

Consider this example corpus:"The cat sat on the mat. The mat was soft and warm."

 900

The resulting bi-gram frequency table would contain:

Bigram Count
The cat 1
cat sat 1
sat on 1
on the 1
the mat 2
mat The 1
was soft 1
soft and 1
and warm 1

When processing the input "The," the model examines all bi-grams beginning with "The":

  • "The cat" (1 occurrence)

  • "The mat" (2 occurrences)

The model predicts "mat" as the most probable next word based on frequency.

While effective for demonstration, bigram models suffer from severe contextual limitations, because the consider only one preceding word for their predictions.

N-gram Models

The n-gram model extends the bi-gram concept by incorporating longer contextual windows . A trigram model, for example, considers two preceding words, while an n-gram model employs n-1 words of context.

Let's look at the sentence: "Thank you very much for your cooperation. I very much appreciated it. We very much made progress."

A trigram model encountering "you very" would leverage both "you" and "very" to predict "much," using conditional probability P("much" | "you", "very").

The relationship between context length and model performance involves critical trade-offs:

Longer Context (Higher n):
  • Captures richer contextual dependencies

  • Enables more coherent text generation

  • Increases model complexity and _parameter_space

  • Higher risk of data sparsity

Shorter Context (Lower n):
  • Simpler models with fewer parameters

  • More robust probability estimates

  • Limited contextual understanding

  • Reduced coherence in generated text

Data sparsity becomes increasingly problematic as n increases—many n-grams may not appear frequently enough in training data to provide reliable probability estimates.

2. The Transformer Revolution: "Attention is All You Need"

The transformer architecture, introduced by Google researchers, revolutionized natural language processing by solving the contextual limitations of n-gram models through sophisticated attention mechanisms .

Vector Representations

Transformers convert words and tokens into high-dimensional vectors ( embeddings ) that capture semantic and syntactic relationships. Unlike sequential models that process text word-by-word, transformers can analyze relationships between all words in a passage simultaneously.

Vector Dimensionality:
  • 2D vectors contain 2 numbers (analogous to map coordinates)

  • 3D vectors contain 3 numbers (spatial coordinates)

  • LLM vectors are high-dimensional with hundreds or thousands of dimensions

Each dimension in an vector space captures different aspects of meaning, enabling the model to represent complex relationships between words and concepts. Vectors occupying similar positions in this space represent semantically related concepts.

Attention Mechanisms

An attention mechanism functions as a dynamic spotlight, highlighting relevant information during text processing. For each token , the model calculates attention weights determining how much focus to allocate to every other token in the context.

Key Advantages:
  • Long-range Dependencies: Links related information across distant text portions

  • Context-Aware Processing: Resolves ambiguous words based on surrounding context

  • Parallel Processing: Analyzes all relationships simultaneously rather than sequentially

Attention Architecture

Attention mechanisms operate through three primary components for each token :

  1. Query Vector: Represents what the current token is "looking for"

  2. Key Vector: Represents what each token "offers" as context

  3. Value Vector: Contains the actual information to be combined

Processing Steps:
  1. Generate query, key, and value vectors for each token

  2. Compare the current token's query with all tokens' keys

  3. Calculate attention scores indicating relevance strength

  4. Use scores to weight value vectors

  5. Combine weighted values to produce final token representation

Attention Weight Properties:
  • Higher weights indicate stronger relevance

  • Weights are normalized to form probability distributions (sum to 1)

  • Enable the model to focus on the most contextually relevant information

Self-Attention:

Every token in a sequence attends to all others, including itself, capturing comprehensive contextual relationships across the entire sequence.

Technical Glossary

attention mechanism

Mechanisms that enable models to weigh the importance of different input portions relative to each other, focusing on the most relevant information for accurate and coherent output generation.

bigram model

Statistical models that predict the next word based on the immediately preceding word, analyzing word pair frequencies to determine probability distributions.

byte-pair encoding

An algorithm for creating efficient tokenization by:

  1. Counting character frequencies in the corpus

  2. Identifying the most common character pairs

  3. Adding common pairs to the vocabulary

  4. Iteratively building tokens from frequent patterns

context

The surrounding words or sequences that inform next-word prediction. Context length varies by model type— bigram models use 1 word, trigram models use 2 words, and ngram models use n-1 words of context.

corpus

The comprehensive dataset of texts used for model training, typically including diverse sources such as books, articles, websites, and other written materials. Corpus quality and diversity directly impact model performance.

data sparsity

Insufficient coverage of possible inputs or features in training data, where certain patterns may not appear frequently enough to provide reliable probability estimates.

embedding

Embeddings transform symbolic language into mathematical forms (the vector) that neural networks can process, with similar concepts positioned closer together in the vector space.

The terms "embedding" and "vector" are often used interchangeably in machine learning contexts, though "embedding" specifically speaks to the process of transforming data into the vector form, whereas "an embedding" likely speaks to a vector—unless a new model is invented that doesn't use vectors.

epoch

One complete pass through the entire training dataset, during which the model processes all examples and updates parameters based on prediction errors.

hyperparameter

A configuration setting that influences model behavior but is not learned during training. Examples include learning rate, batch size, and temperature. Hyperparameters are typically set before training begins and can significantly impact model performance.

long range dependencies

Relationships between words or phrases separated by significant distances in text, such as pronouns referring to entities in different paragraphs.

loss

A metric measuring prediction accuracy by quantifying the difference between model outputs and correct answers. Training progressively reduces loss through parameter optimization.

n-gram model

Describes the general version of a bigram model. It is a statistical language modeling approach that predicts words based on n-1 previous words in sequence. Common variants include bigrams (n=2), trigrams (n=3).

over-fitting

A condition where models perform exceptionally on training data but fail to generalize to new, unseen inputs—analogous to memorizing without understanding.

parameter

The individual weights and biases within a model that are adjusted during training to minimize prediction error. Parameters are learned from the training data and define the model's behavior.

parameter space

The multidimensional mathematical domain that encompasses all the weights and biases that the model can learn, which can number in the millions or billions for modern language models.

preprocessing

Data preparation steps including cleaning, transformation, and structuring to optimize datasets for machine learning, such as lowercasing text or removing stop words.

temperature

A hyperparameter controlling output randomness:

  • Lower Temperature: More deterministic, focused responses with higher probability words

  • Higher Temperature: Increased randomness and creativity, selecting less probable words

Not to be confused with the parameter space.

token

The fundamental unit of text processed by language models, representing a piece of text produced through tokenization. Tokens can be words, subwords, characters, or other linguistic units depending on the tokenization method used.

tokenization

The process of segmenting text into smaller units (tokens) such as words, subwords, or characters. Effective tokenization increases training examples and enables models to learn morphological patterns.

training

The process of optimizing the model's parameter space to maximize prediction accuracy, expressed as f(x|params) where x represents input and params represents learned weights.

transformer

A neural network architecture that consists of encoders (for understanding input) and decoders (for generating output), or decoder-only (for generative tasks). Transformers process all tokens in parallel rather than sequentially and better capture long range context.

vector

High-dimensional numerical representations of text or data, capturing semantic and syntactic relationships in mathematical space suitable for computational processing.

]]>
<![CDATA[Part 4: Angular 19 Deep Dive – Smarter Forms with Signals and Control Flow]]> https://blog.lunatech.com/posts/2025-05-20-part-4-angular-19-deep-dive-smarter-forms-with-signals-and-control-flow/ https://blog.lunatech.com/posts/2025-05-20-part-4-angular-19-deep-dive-smarter-forms-with-signals-and-control-flow/ Tue, 20 May 2025 00:00:00 +0000

Welcome back to the Full-Stack Authentication Boilerplate (Angular


NestJS + PostgreSQL) series. So far, we’ve wired up the backend and built a working Angular frontend. Now it’s time to modernize our forms using the latest Angular 19 features—specifically Signals, *control flow syntax, and defer blocks*.

Angular 19 isn’t a total rewrite—it’s a refinement. It tightens up template logic, improves reactivity, and lets us write cleaner, more performant UI code. In this part, we’ll refactor the login and register forms to take full advantage of these improvements.

'''''

Angular 19 Recap (and What Actually Matters for Devs)

Here’s what we’ll use in this part:

  • Signals: Angular’s native reactive primitive, lets you track state like useState() in React—but fully integrated into Angular’s change detection.

  • Control Flow Syntax (@if, @for, @switch): Cleaner alternatives to structural directives like *ngIf and *ngFor.

  • Defer Blocks: Lazy-load parts of the UI, just like backend routes or feature modules.

  • @let Directive: Declare local variables directly in templates.

  • Smarter Change Detection: Updates only the DOM parts that actually changed, for faster UIs.

For backend devs: Think of signal() like a reactive field or an in-memory tracked variable; @if/@for in your templates is like having expressive, inline logic with instant UI updates—no more verbose boilerplate.

'''''

Signals or Reactive Forms?

When to Use Each (for Backend Devs):
  • Signals are perfect for local/component UI state and simple forms.

  • Reactive Forms (FormBuilder, etc.) are the gold standard for complex, validation-heavy, or dynamic forms—especially if you need granular error handling or will scale up forms later.

  • Pro Tip: You can use both! Track form state with Reactive Forms, then reflect with signals using Angular’s toSignal() utility for the best DX.

// Example: Bridge form values to signals
formValue = toSignal(form.valueChanges, { initialValue: form.value });

'''''

Migration: NgModules → Standalone Components

Old way:
@NgModule({
  declarations: [LoginComponent],
  imports: [ReactiveFormsModule],
})
export class AuthModule {}
New way (Angular 15+):
@Component({
  standalone: true,
  selector: 'app-login',
  templateUrl: './login.component.html',
  imports: [ReactiveFormsModule],
})
export class LoginComponent {}
  • No more @NgModule needed.

  • Use standalone: true and import dependencies directly in the component.

  • Both patterns can coexist during migration.

'''''

Refactoring the Login Form

Here’s how to use Reactive Forms as the foundation, enhanced with signals and modern control flow blocks.

login.component.ts

import {
  Component,
  computed,
  signal,
  inject,
  ChangeDetectionStrategy,
} from '@angular/core';
import { Router } from '@angular/router';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { AuthService } from '../../services/auth.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { take } from 'rxjs/operators';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.scss'],
  standalone: true,
  imports: [ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoginComponent {
  private readonly fb = inject(FormBuilder);
  readonly form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
  });

  readonly isLoading = signal(false);
  readonly errorMessage = signal<string | null>(null);

  readonly formValue = toSignal(this.form.valueChanges, {
    initialValue: this.form.value,
  });
  readonly formStatus = toSignal(this.form.statusChanges, {
    initialValue: this.form.status,
  });
  readonly isFormValid = computed(() => this.formStatus() === 'VALID');

  constructor(
    private readonly auth: AuthService,
    private readonly router: Router
  ) {}

  onSubmit() {
    if (!this.isFormValid()) return;

    const { email, password } = this.form.getRawValue();
    if (!email || !password) return;

    this.isLoading.set(true);
    this.errorMessage.set(null);

    this.auth
      .login({ email, password })
      .pipe(take(1))
      .subscribe({
        next: () => {
          this.isLoading.set(false);
          this.router.navigate(['/welcome']);
        },
        error: (err) => {
          this.isLoading.set(false);
          const message =
            err?.error?.message ||
            err?.message ||
            'Login failed. Please try again.';
          this.errorMessage.set(message);
        },
      });
  }
}

'''''

Migration: Control Flow Syntax

Before (classic Angular):
<div *ngIf="loading">Loading...</div>
<ul>
  <li *ngFor="let user of users">{{ user.name }}</li>
</ul>
After (Angular 17+):
@if (loading) {
  <div>Loading...</div>
}
<ul>
  @for (user of users) {
    <li>{{ user.name }}</li>
  }
</ul>
  • The new syntax is cleaner and more readable.

  • You can use both during your migration to Angular 17+.

'''''

login.component.html

<form
  [formGroup]="form"
  (ngSubmit)="onSubmit()"
  class="mt-4 p-4 border rounded shadow-sm bg-white"
  style="max-width: 400px; margin: auto"
>
  <h2 class="text-center mb-4">Login</h2>

  <div class="mb-3">
    @let isInvalidEmail = form.get('email')?.invalid && form.get('email')?.touched;
    <input
      formControlName="email"
      type="email"
      class="form-control"
      placeholder="Email"
      [class.is-invalid]="isInvalidEmail"
      aria-label="Email"
    />
    @if (isInvalidEmail) {
      <div class="invalid-feedback">Please enter a valid email.</div>
    }
  </div>

  <div class="mb-3">
    @let isInvalidPassword = form.get('password')?.invalid && form.get('password')?.touched;
    <input
      formControlName="password"
      type="password"
      class="form-control"
      placeholder="Password"
      [class.is-invalid]="isInvalidPassword"
      aria-label="Password"
    />
    @if (isInvalidPassword) {
      <div class="invalid-feedback">
        Password must be at least 6 characters long.
      </div>
    }
  </div>

  @if (errorMessage()) {
    <div class="alert alert-danger">{{ errorMessage() }}</div>
  }

  <button
    type="submit"
    class="btn btn-primary w-100"
    [disabled]="isLoading() || !isFormValid()"
  >
    @if (isLoading()) {
      <span class="spinner-border spinner-border-sm me-2"></span>
    }
    Login
  </button>
</form>

'''''

Refactoring the Register Form

register.component.ts

import {
  Component,
  computed,
  signal,
  inject,
  ChangeDetectionStrategy,
} from '@angular/core';
import { Router } from '@angular/router';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { AuthService } from '../../services/auth.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { take } from 'rxjs/operators';

@Component({
  selector: 'app-register',
  templateUrl: './register.component.html',
  styleUrls: ['./register.component.scss'],
  standalone: true,
  imports: [ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RegisterComponent {
  private readonly fb = inject(FormBuilder);
  readonly form = this.fb.group({
    name: ['', [Validators.required]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
    role: ['user', [Validators.required]],
  });

  readonly isLoading = signal(false);
  readonly errorMessage = signal<string | null>(null);

  readonly formValue = toSignal(this.form.valueChanges, {
    initialValue: this.form.value,
  });
  readonly formStatus = toSignal(this.form.statusChanges, {
    initialValue: this.form.status,
  });
  readonly isFormValid = computed(() => this.formStatus() === 'VALID');

  constructor(
    private readonly auth: AuthService,
    private readonly router: Router
  ) {}

  onSubmit() {
    if (!this.isFormValid()) return;

    const { name, email, password, role } = this.form.getRawValue();
    if (!name || !email || !password || !role) return;

    this.isLoading.set(true);
    this.errorMessage.set(null);

    this.auth
      .register({ name, email, password, role })
      .pipe(take(1))
      .subscribe({
        next: () => {
          this.isLoading.set(false);
          this.router.navigate(['/login']);
        },
        error: (err) => {
          this.isLoading.set(false);
          const message =
            err?.error?.message ||
            err?.message ||
            'Registration failed. Please try again.';
          this.errorMessage.set(message);
        },
      });
  }
}

'''''

register.component.html

<form
  [formGroup]="form"
  (ngSubmit)="onSubmit()"
  class="mt-4 p-4 border rounded shadow-sm bg-white"
  style="max-width: 400px; margin: auto"
>
  <h2 class="text-center mb-4">Register</h2>

  <div class="mb-3">
    @let isInvalidName = form.get('name')?.invalid && form.get('name')?.touched;
    <input
      formControlName="name"
      type="text"
      class="form-control"
      placeholder="Name"
      [class.is-invalid]="isInvalidName"
      aria-label="Name"
    />
    @if (isInvalidName) {
      <div class="invalid-feedback">Name is required.</div>
    }
  </div>

  <div class="mb-3">
    @let isInvalidEmail = form.get('email')?.invalid && form.get('email')?.touched;
    <input
      formControlName="email"
      type="email"
      class="form-control"
      placeholder="Email"
      [class.is-invalid]="isInvalidEmail"
      aria-label="Email"
    />
    @if (isInvalidEmail) {
      <div class="invalid-feedback">Please enter a valid email.</div>
    }
  </div>

  <div class="mb-3">
    @let isInvalidPassword = form.get('password')?.invalid && form.get('password')?.touched;
    <input
      formControlName="password"
      type="password"
      class="form-control"
      placeholder="Password"
      [class.is-invalid]="isInvalidPassword"
      aria-label="Password"
    />
    @if (isInvalidPassword) {
      <div class="invalid-feedback">
        Password must be at least 6 characters long.
      </div>
    }
  </div>

  @if (errorMessage()) {
    <div class="alert alert-danger">{{ errorMessage() }}</div>
  }

  <button
    type="submit"
    class="btn btn-success w-100"
    [disabled]="isLoading() || !isFormValid()"
  >
    @if (isLoading()) {
      <span class="spinner-border spinner-border-sm me-2"></span>
    }
    Register
  </button>
</form>

'''''

Real-World Patterns for Backend Devs

✅ Signals vs Observables

Rule of Thumb: Use signals for local, component-level UI state. Use observables for async or backend-driven data.

Signals (local state):
isModalOpen = signal(false);

openModal() { this.isModalOpen.set(true); }
closeModal() { this.isModalOpen.set(false); }
@if (isModalOpen()) {
  <app-modal (close)="closeModal()"></app-modal>
}
Observables (async state):
private modalSubject = new BehaviorSubject(false);
isModalOpen$ = this.modalSubject.asObservable();

openModal() { this.modalSubject.next(true); }
closeModal() { this.modalSubject.next(false); }
@let isOpen = (isModalOpen$ | async); 
@if (isOpen) {
  <app-modal (close)="closeModal()"></app-modal>
}

'''''

✅ Control Flow in Action

Using @for instead of `ngFor`:*

<ul>
  @for (user of users; track user.id) {
    <li>{{ user.name }}</li>
  }
</ul>

Using @switch vs `ngSwitch`:*

@switch (status) {
  @case ('loading') {
    <p>Loading...</p>
  } @case ('error') {
    <p>Error!</p>
  } @default {
    <p>All good.</p>
  } 
}

Backend parallel: @if, @for, and @switch are like inline logic in your backend template engines (e.g., EJS, Razor, Thymeleaf)—but here they’re fully reactive and type-safe.

'''''

✅ Lazy Loading and UX Patterns with @defer

Angular’s @defer block is great for loading UI only when needed.

Basic Usage:
@defer (when isHeavyComponentVisible) {
  <app-heavy-widget></app-heavy-widget>
} @placeholder {
  <p>Loading widget...</p>
}
Loading feedback:
@defer (when dataReady; loading minimum 300ms) {
  <app-dashboard></app-dashboard>
} @loading {
  <p>Loading dashboard...</p>
} @placeholder {
  <p>Initializing view...</p>
}
Viewport entry:
@defer (on viewport) {
  <app-news-feed></app-news-feed>
} @placeholder {
  <p>Loading news feed when visible...</p>
}
Idle trigger:
@defer (on idle) {
  <app-recommendations></app-recommendations>
}

Why care? Optimizes TTI (time to interactive) on heavy or mobile pages.

'''''

✅ Smart Change Detection in a Dashboard

userCount = signal(0);
orderTotal = signal(0);

ngOnInit() {
  this.api.getUsers().subscribe(users => this.userCount.set(users.length));
  this.api.getOrders().subscribe(orders => this.orderTotal.set(orders.length));
}
<div>Users: {{ userCount() }}</div>
<div>Orders: {{ orderTotal() }}</div>
Signals update just what changed—no wasted DOM re-renders.

'''''

SSR & Hydration (Bonus Note)

💡 SSR & Hydration: Angular 19’s hydration improvements are especially useful if you’re rendering Angular on the server (Angular Universal). Most projects won’t need this—but it’s a great step for future-proofing.

'''''

Final Thoughts

Angular 19 brings a more approachable and modern developer experience: signals simplify state, @if and @for make templates more readable, and @defer gives you fine control over performance.

You just:
  • Replaced legacy form logic with clean Signals-based state.

  • Simplified templates using Angular’s new control flow.

  • Learned how to delay rendering and optimize performance with @defer.

Next: We’ll bring everything together in Part 5 with route guards, token parsing, and role-based access control.

'''''

]]>
<![CDATA[Part 3: Frontend Setup with Angular]]> https://blog.lunatech.com/posts/2025-05-06-part-3-frontend-setup-with-angular/ https://blog.lunatech.com/posts/2025-05-06-part-3-frontend-setup-with-angular/ Tue, 06 May 2025 00:00:00 +0000

Now that we’ve built our NestJS backend with secure JWT auth, it’s time to wire up the frontend. In this part, we’ll scaffold an Angular app, integrate it with our backend, and secure it with *JWT-based route protection*.

By the end of this part, you’ll have:

  • A basic Angular app styled with Bootstrap

  • Auth service for login/register with JWT handling

  • Login and Register components using reactive forms

  • A protected Welcome page

  • Route guard to lock down private views

'''''

Step 1: Initialize the Angular Frontend

Let’s get your frontend up and running. Open your terminal and run:

ng new frontend
cd frontend

Pick SCSS (or your favorite style option) when prompted.


This gives you a fresh Angular workspace to play with.

What is ng in Angular CLI?

The ng command is the core of the Angular CLI (Command Line Interface) — a powerful toolkit that streamlines Angular development from start to finish. It acts as a shortcut to perform a wide range of tasks without manually setting up files or configurations.

With ng, you can quickly scaffold new projects, generate application features, and manage your build and development workflow with ease. Here are some common ng commands and what they do:

  • ng new: Creates a new Angular project with a complete directory structure, TypeScript configuration, and ready-to-run setup.

  • ng generate (or ng g): Automatically creates Angular building blocks such as components, services, directives, pipes, and more — following Angular’s style guide and best practices.

  • ng serve: Launches a local development server with live reload. As you make changes, the app updates instantly in the browser.

  • ng build: Compiles your application into optimized static files for production deployment.

The real power of ng lies in automation. It eliminates repetitive boilerplate and helps you stick to Angular conventions, leading to cleaner, more maintainable codebases.

💡 Tip: With Angular v19, components and services are created asstandalone by default. This means they no longer need to be declared in NgModules, which promotes better modularity and reduces coupling in your app architecture.

By mastering the ng CLI, you’re not just saving time — you’re building apps the Angular way, with a foundation rooted in scalability and consistency.

'''''

Step 2: Install Dependencies

We’ll need a few extras to make our app functional and visually appealing:

  • Bootstrap: For responsive and professional styling.

  • jwt-decode: To decode JWTs and extract user information.

Install them with:

npm install bootstrap jwt-decode

'''''

Step 3: Set Up Bootstrap

Let’s make things look good with minimal effort.

  1. Open angular.json

  2. Find the "styles" array and add Bootstrap’s CSS:

    "styles": [
      "node_modules/bootstrap/dist/css/bootstrap.min.css",
      "src/styles.scss"
    ]
  3. Start your development server if it’s not already running:
    ng serve

Test it by adding a Bootstrap class (like btn btn-primary) to a button in app.component.html. If it’s blue, you’re golden.

'''''

Step 4: Setting Up Environments

Angular provides a built-in way to manage environment-specific configurations, such as API URLs for development and production. Let’s configure them.

  1. Run the following command to generate the environment files for development and production:
    ng generate environments

    This will create two files in the src/environments/ folder:

    src/
    ├── environments/
    │   ├── environment.development.ts # Development environment
    │   └── environment.ts             # Production environment
  2. Open both environment.ts and environment.development.ts and add the apiUrl property. environment.development.ts

    export const environment = {
      production: false,
      apiUrl: "http://localhost:3000", // Replace with your backend's dev URL
    };
    environment.ts
    export const environment = {
      production: true,
      apiUrl: "https://your-production-api.com", // Replace with your backend's prod URL
    };
  3. The environment files are automatically selected based on the build configuration:
    • Development: When running ng serve, Angular replacesenvironment.ts with environment.development.ts.

    • Production: when running ng build --prod, Angular usesenvironment.ts.

    You can now use environment.apiUrl in your services to dynamically switch between development and production APIs.

'''''

Step 5: Generate Guards, Services, and Components

Let Angular CLI do the heavy lifting for you. Run the following commands to generate the necessary files:

ng generate service services/auth
ng generate guard auth/auth
ng generate component pages/login
ng generate component pages/register
ng generate component pages/welcome

What These Commands Do:

  1. Auth Service: Handles API calls for login, registration, and token management.

  2. Auth Guard: Protects routes by checking if the user is authenticated.

  3. Login Component: Provides a form for users to log in.

  4. Register Component: Provides a form for users to register.

  5. Welcome Component: Displays a personalized welcome message for logged-in users.

This sets up your folders and boilerplate files, keeping things organized and modular.

Project Structure Overview

Here’s how your Angular app should look after generating the files:

src/
├── app/
│   ├── auth/                     # Guards, interceptors, maybe a module
│   │   └── auth.guard.ts
│   ├── pages/                    # Feature components
│   │   ├── login/
│   │   │   ├── login.component.html
│   │   │   ├── login.component.scss
│   │   │   └── login.component.ts
│   │   ├── register/
│   │   │   ├── register.component.html
│   │   │   ├── register.component.scss
│   │   │   └── register.component.ts
│   │   └── welcome/
│   │       ├── welcome.component.html
│   │       ├── welcome.component.scss
│   │       └── welcome.component.ts
│   ├── services/                 # API communication
│   │   └── auth.service.ts
│   ├── shared/                   # Models, utilities, etc. (optional)
│   ├── app.component.html
│   ├── app.component.scss
│   ├── app.component.ts
│   ├── app.config.ts
│   └── app.routes.ts
├── environments/
│   ├── environment.development.ts # Development environment
│   └── environment.ts             # Production environment
├── index.html
├── main.ts
└── style.scss

'''''

Step 6: The Auth Service – Your API Bridge

The AuthService is the backbone of your authentication flow. It communicates with your backend’s /auth/login and /auth/registerendpoints, manages the JWT in localStorage, and provides utility methods for token handling.

Here’s the complete implementation:

// src/app/services/auth.service.ts
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { catchError, Observable, tap, throwError } from "rxjs";
import { environment } from "../../environments/environment";

@Injectable({
  providedIn: "root",
})
export class AuthService {
  constructor(private http: HttpClient) {}

  /**
   * Logs in the user by sending their credentials to the backend.
   * Stores the access token in localStorage upon success.
   * @param data - The user's email and password.
   * @returns An observable containing the access token.
   */
  login(data: {
    email: string;
    password: string;
  }): Observable<{ access_token: string }> {
    return this.http
      .post<{ access_token: string }>(`${environment.apiUrl}/auth/login`, data)
      .pipe(
        tap((res) => localStorage.setItem("access_token", res.access_token)),
        catchError((error) => {
          const errorMessage =
            error.status === 401
              ? "Invalid email or password. Please try again."
              : "An unexpected error occurred. Please try again later.";
          return throwError(() => new Error(errorMessage));
        })
      );
  }

  /**
   * Registers a new user by sending their details to the backend.
   * @param data - The user's name, email, password, and role.
   * @returns An observable for the registration process.
   */
  register(data: {
    email: string;
    password: string;
    name: string;
    role: string;
  }): Observable<any> {
    return this.http.post(`${environment.apiUrl}/auth/register`, data).pipe(
      catchError((error) => {
        const errorMessage =
          error.status === 400
            ? "Registration failed. Please check your input."
            : "An unexpected error occurred. Please try again later.";
        return throwError(() => new Error(errorMessage));
      })
    );
  }

  /**
   * Logs out the user by removing the access token from localStorage.
   */
  logout(): void {
    localStorage.removeItem("access_token");
  }

  /**
   * Retrieves the stored access token from localStorage.
   * @returns The access token or null if not found.
   */
  getToken(): string | null {
    return localStorage.getItem("access_token");
  }

  /**
   * Checks if the user is authenticated by verifying the presence of a token.
   * @returns A boolean indicating whether the user is authenticated.
   */
  isAuthenticated(): boolean {
    const token = this.getToken();
    return !!token;
  }
}

'''''

Step 7: Protecting Routes with a Guard

Angular’s CanActivate guard is like a backend middleware for your routes. Here’s how we check for a valid JWT:

// src/app/auth/auth.guard.ts
import { Injectable } from "@angular/core";
import { CanActivate, Router } from "@angular/router";
import { AuthService } from "../services/auth.service";
import { jwtDecode } from "jwt-decode";

@Injectable({ providedIn: "root" })
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(): boolean {
    const token = this.authService.getToken();
    if (!token) {
      this.redirectToLogin();
      return false;
    }

    if (this.isTokenExpired(token)) {
      this.authService.logout();
      this.redirectToLogin();
      return false;
    }

    return true;
  }

  /**
   * Checks if the token is expired.
   * @param token - The JWT token to validate.
   * @returns A boolean indicating whether the token is expired.
   */
  private isTokenExpired(token: string): boolean {
    try {
      const decoded: any = jwtDecode(token);
      return Date.now() > decoded.exp * 1000;
    } catch {
      return true; // Treat invalid tokens as expired
    }
  }

  /**
   * Redirects the user to the login page.
   */
  private redirectToLogin(): void {
    this.router.navigate(["/login"]);
  }
}

'''''

Step 8: Login & Register Components

The LoginComponent and RegisterComponent are the core components for user authentication in the application. Both components use Angular’s reactive forms to manage user input and validations. They interact with the AuthService to send requests to the backend for login and registration functionality. Upon successful operations, they navigate the user to the appropriate page (/welcome for login and /login for registration).

Key Features

  • Reactive Forms: Both components use Angular’s reactive forms to handle user input and validations.

  • Validation Rules: Fields like email, password, and name (for registration) have validation rules to ensure proper input.

  • Error Handling: User-friendly error messages are displayed when login or registration fails.

  • Loading State: Prevents duplicate submissions by disabling the submit button while the request is in progress.

  • Navigation: Redirects users to the appropriate page upon successful login or registration.

'''''

  1. Login Component
    // src/app/pages/login/login.component.ts
    import { Component } from "@angular/core";
    import { Router } from "@angular/router";
    import {
      FormBuilder,
      FormGroup,
      ReactiveFormsModule,
      Validators,
    } from "@angular/forms";
    import { AuthService } from "../../services/auth.service";
    import { CommonModule } from "@angular/common";
    
    @Component({
      selector: "app-login",
      templateUrl: "./login.component.html",
      styleUrls: ["./login.component.scss"],
      imports: [ReactiveFormsModule, CommonModule],
    })
    export class LoginComponent {
      form: FormGroup;
      isLoading = false;
      errorMessage: string | null = null;
    
      constructor(
        private fb: FormBuilder,
        private auth: AuthService,
        private router: Router
      ) {
        this.form = this.fb.group({
          email: ["", [Validators.required, Validators.email]],
          password: ["", [Validators.required, Validators.minLength(6)]],
        });
      }
    
      onSubmit() {
        if (this.form.invalid) return;
    
        this.isLoading = true;
        this.errorMessage = null;
    
        this.auth.login(this.form.value).subscribe({
          next: () => {
            this.isLoading = false;
            this.router.navigate(["/welcome"]);
          },
          error: (err) => {
            this.isLoading = false;
            this.errorMessage =
              err.error?.message || "Login failed. Please try again.";
          },
        });
      }
    }
  2. Login HTML
    <!-- src/app/pages/login/login.component.html -->
    <form
      [formGroup]="form"
      (ngSubmit)="onSubmit()"
      class="mt-4 p-4 border rounded shadow-sm bg-white"
      style="max-width: 400px; margin: auto"
    >
      <h2 class="text-center mb-4">Login</h2>
    
      <div class="mb-3">
        <input
          formControlName="email"
          type="email"
          class="form-control"
          placeholder="Email"
          [class.is-invalid]="
            form.get('email')?.invalid && form.get('email')?.touched
          "
          aria-label="Email"
        />
        <div
          *ngIf="form.get('email')?.invalid && form.get('email')?.touched"
          class="invalid-feedback"
        >
          Please enter a valid email.
        </div>
      </div>
    
      <div class="mb-3">
        <input
          formControlName="password"
          type="password"
          class="form-control"
          placeholder="Password"
          [class.is-invalid]="
            form.get('password')?.invalid && form.get('password')?.touched
          "
          aria-label="Password"
        />
        <div
          *ngIf="form.get('password')?.invalid && form.get('password')?.touched"
          class="invalid-feedback"
        >
          Password must be at least 6 characters long.
        </div>
      </div>
    
      <div *ngIf="errorMessage" class="alert alert-danger">
        {{ errorMessage }}
      </div>
    
      <button type="submit" class="btn btn-primary w-100" [disabled]="isLoading">
        <span
          *ngIf="isLoading"
          class="spinner-border spinner-border-sm me-2"
        ></span>
        Login
      </button>
    </form>
  3. Register Component
    // src/app/pages/register/register.component.ts
    import { Component } from "@angular/core";
    import { Router } from "@angular/router";
    import {
      FormBuilder,
      FormGroup,
      ReactiveFormsModule,
      Validators,
    } from "@angular/forms";
    import { AuthService } from "../../services/auth.service";
    import { CommonModule } from "@angular/common";
    
    @Component({
      selector: "app-register",
      templateUrl: "./register.component.html",
      styleUrls: ["./register.component.scss"],
      imports: [ReactiveFormsModule, CommonModule],
    })
    export class RegisterComponent {
      form: FormGroup;
      isLoading = false;
      errorMessage: string | null = null;
    
      constructor(
        private fb: FormBuilder,
        private auth: AuthService,
        private router: Router
      ) {
        this.form = this.fb.group({
          name: ["", [Validators.required]],
          email: ["", [Validators.required, Validators.email]],
          password: ["", [Validators.required, Validators.minLength(6)]],
          role: ["user", [Validators.required]],
        });
      }
    
      onSubmit() {
        if (this.form.invalid) return;
    
        this.isLoading = true;
        this.errorMessage = null;
    
        this.auth.register(this.form.value).subscribe({
          next: () => {
            this.isLoading = false;
            this.router.navigate(["/login"]);
          },
          error: (err) => {
            this.isLoading = false;
            this.errorMessage =
              err.error?.message || "Registration failed. Please try again.";
          },
        });
      }
    }
  4. Register HTML
    <form
      [formGroup]="form"
      (ngSubmit)="onSubmit()"
      class="mt-4 p-4 border rounded shadow-sm bg-white"
      style="max-width: 400px; margin: auto"
    >
      <h2 class="text-center mb-4">Register</h2>
    
      <div class="mb-3">
        <input
          formControlName="name"
          type="text"
          class="form-control"
          placeholder="Name"
          [class.is-invalid]="
             form.get('name')?.invalid && form.get('name')?.touched
           "
          aria-label="Name"
        />
        <div
          *ngIf="form.get('name')?.invalid && form.get('name')?.touched"
          class="invalid-feedback"
        >
          Name is required.
        </div>
      </div>
    
      <div class="mb-3">
        <input
          formControlName="email"
          type="email"
          class="form-control"
          placeholder="Email"
          [class.is-invalid]="
             form.get('email')?.invalid && form.get('email')?.touched
           "
          aria-label="Email"
        />
        <div
          *ngIf="form.get('email')?.invalid && form.get('email')?.touched"
          class="invalid-feedback"
        >
          Please enter a valid email.
        </div>
      </div>
    
      <div class="mb-3">
        <input
          formControlName="password"
          type="password"
          class="form-control"
          placeholder="Password"
          [class.is-invalid]="
             form.get('password')?.invalid && form.get('password')?.touched
           "
          aria-label="Password"
        />
        <div
          *ngIf="form.get('password')?.invalid && form.get('password')?.touched"
          class="invalid-feedback"
        >
          Password must be at least 6 characters long.
        </div>
      </div>
    
      <div *ngIf="errorMessage" class="alert alert-danger">
        {{ errorMessage }}
      </div>
    
      <button type="submit" class="btn btn-success w-100" [disabled]="isLoading">
        <span
          *ngIf="isLoading"
          class="spinner-border spinner-border-sm me-2"
        ></span>
        Register
      </button>
    </form>

'''''

Step 9: Welcome Page – Static Greeting and Logout

The WelcomeComponent provides a user-friendly page that welcomes the user after a successful login. It integrates with the existing AuthService to manage logout functionality but does not attempt to decode or extract any user-specific data from the access token.

Key Features

  • Static Greeting: Display a generic welcome message for all users.

  • Logout Functionality: Allow users to log out and clear their session.

'''''

  1. Welcome Component
    // src/app/pages/welcome/welcome.component.ts
    import { Component } from "@angular/core";
    import { AuthService } from "../../services/auth.service";
    import { Router } from "@angular/router";
    
    @Component({
      selector: "app-welcome",
      templateUrl: "./welcome.component.html",
      styleUrls: ["./welcome.component.scss"],
    })
    export class WelcomeComponent {
      constructor(private authService: AuthService, private router: Router) {}
    
      logout(): void {
        this.authService.logout();
        this.router.navigate(["/login"]);
      }
    }
  2. Welcome HTML
    <!-- src/app/pages/welcome/welcome.component.html -->
    <div class="welcome-container text-center mt-5">
      <h1 class="display-4">Welcome!</h1>
      <p class="lead">We're glad to have you here.</p>
      <button class="btn btn-primary mt-3" (click)="logout()">Logout</button>
    </div>
  3. Welcome Component SCSS
    /* /src/app/pages/welcome/welcome.component.scss */
    .welcome-container {
      max-width: 600px;
      margin: auto;
      padding: 20px;
      background-color: #f8f9fa;
      border-radius: 8px;
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }
    
    h1 {
      color: #343a40;
    }
    
    p {
      color: #6c757d;
    }

'''''

Step 10: Routing: Lock Down Protected Pages

Alright, now that we’ve got our login, registration, and welcome pages ready, it’s time to lock things down. We don’t want just anyone accessing the protected parts of our app, right? That’s where Angular’s routing and guards come into play. Let’s set up our routes and ensure only authenticated users can access the welcome page.

Setting Up Routes

In Angular, routes define how users navigate through your app. Here’s how we’ll structure our routes:

  • /login: For users to log in.

  • /register: For new users to sign up.

  • /welcome: A protected page that greets logged-in users.

  • /: Redirects to /welcome by default.

Here’s the code:

// src/app/app.routes.ts
import { Routes } from "@angular/router";
import { LoginComponent } from "./pages/login/login.component";
import { RegisterComponent } from "./pages/register/register.component";
import { WelcomeComponent } from "./pages/welcome/welcome.component";
import { AuthGuard } from "./auth/auth.guard";

export const routes: Routes = [
  { path: "", redirectTo: "welcome", pathMatch: "full" },
  { path: "login", component: LoginComponent },
  { path: "register", component: RegisterComponent },
  { path: "welcome", component: WelcomeComponent, canActivate: [AuthGuard] },
];

This is a simple and clean way to define your app’s navigation. Notice how we’ve added canActivate: [AuthGuard] to the /welcome route? That’s the magic that protects it.

Protecting Routes with a Guard

The AuthGuard is like a bouncer for your routes. It checks if the user is authenticated before letting them in. If they’re not, it redirects them to the login page.

Wiring It All Together

Now that we’ve defined our routes and guard, let’s hook everything up inmain.ts. Angular v19 makes this super simple with the provideRouterfunction:

// src/main.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
import { provideRouter } from "@angular/router";
import { routes } from "./app/app.routes";
import { provideHttpClient } from "@angular/common/http";

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes), // Provide the routes
    provideHttpClient(), // Provide the HTTP client
  ],
}).catch((err) => console.error(err));

No need for a separate AppRoutingModule. Just provide the routes directly in main.ts, and you’re good to go.

Testing It Out

  1. Try Accessing /welcome Without Logging In:
    • You should be redirected to /login.

  2. Log In and Access /welcome:
    • After logging in, navigate to /welcome. You should see the personalized greeting.

  3. Log Out and Try Again:
    • After logging out, try accessing /welcome again. You should be redirected to /login.

Why This Matters

For backend developers, this should feel familiar. Think of theAuthGuard as middleware for your routes. It ensures that only authenticated users can access certain parts of your app, just like how you’d protect API endpoints on the server side.

By combining Angular’s routing with guards, you’ve got a solid foundation for building secure, user-friendly apps. And the best part? It’s modular and easy to extend. Want to add role-based access? Just tweak the guard. Need to protect more routes? Add canActivate to them.

'''''

What’s Next?

You’ve got a working Angular app wired to your backend, protected with route guards and powered by JWT auth. Next up, we’ll level things up with some Angular 19 enhancements and best practices.

👉 Part 4: Angular 19 Deep Dive

We’ll cover:

  • 🧠 Signals vs Observables — When to use which and why

  • 🔁 @if, @for, and defer blocks — Smarter templates with better control

  • Change detection and performance tuning

This part’s optional, but worth it if you want your Angular app to be leaner, faster, and future-ready.

For now, pat yourself on the back—you’ve just implemented a secure, modern routing system in Angular. 🚀

]]>
<![CDATA[AI plugins for code development]]> https://blog.lunatech.com/posts/2025-05-02-ai-plugins-for-code-development/ https://blog.lunatech.com/posts/2025-05-02-ai-plugins-for-code-development/ Fri, 02 May 2025 00:00:00 +0000

Introduction

Recently, I experimented with integrating AI into my IntelliJ IDEA setup for JVM development, and I wanted to share the steps I followed. It turned out to be really helpful, so maybe this guide can assist someone else in getting started too!

IntelliJ is one of the preferred IDE for Java, Scala and Kotlin development. I have been using the IntelliJ IDEA with plugins for AI, which allows me to use Local LLMs to generate code suggestions, refactor my code and even write tests for me.

What I did

Step 1: Install Ollama

First, visit Ollama and download the version for your operating system. Follow the simple installation steps.

Open your terminal and run ollama pull llama3 to download the model. Start the model by running ollama serve llama3.

TIP
Choosing the right model is very important .

Step 2: IntelliJ Plugins

I tested a few plugins and found Continue.dev really useful:

NOTE
A list of plugins at the bottom of this article with links

Continue.dev Helps with code completion, converting comments into code, and debugging tips. Works locally (privacy-friendly), quick. Needs decent hardware resources.

Step 3: Setting Up Continue.dev with IntelliJ

Install the Plugin: Open IntelliJ IDEA, navigate to Settings -> Plugins, search for "Continue.dev", and install it.

Connect to Ollama: Open the Continue.dev settings and add the local Ollama server URL ( http://localhost:11434 ).

Step 4: Exploring AI Features

Code Suggestions: The plugin provided accurate code completions immediately, significantly speeding up my workflow.

Generating Code from Comments: I tried writing simple English comments, and Continue.dev translated them into functioning code snippets effortlessly.

Debugging: Even when intentionally making small errors, the plugin quickly suggested effective fixes.

Step 5: AI-Assisted Refactoring

I highlighted some messy code. Using the AI suggestions via right-click helped clean up and optimize the code efficiently.

Tips for Best Results:

Regularly update plugins and AI models for optimal performance.

If hardware resources are limited, experiment with smaller models initially.

IntelliJ IDEA Plugins Supporting Local LLM Integration

Plugin Features Pros Cons

Continue.dev a|- Code completion - Natural language to code generation - Inline code explanations - Supports multiple local LLM providers

- Open-source - Highly customizable - Supports various local LLMs like Ollama, LM Studio, GPT4All - May require manual configuration for optimal performance

DevoxxGenie a| - Code review and explanation - Unit test generation - Retrieval-Augmented Generation (RAG) - Integration with various local LLMs

- Fully Java-based - Rich feature set including RAG and Git Diff viewer - Supports multiple local LLMs - Interface may be overwhelming for beginners

Proxy AI a| - Connects IntelliJ IDEA with locally running LLMs - Provides AI assistance features

- Open-source - Simple setup for local LLM integration - Limited feature set compared to other plugins

CodeGPT a| - AI-powered code suggestions - Chat interface within IntelliJ IDEA - Supports local LLMs via LM Studio

- User-friendly interface - Supports multiple programming languages - May require additional setup for local LLM integration

JetBrains AI Assistant a| - AI-driven code suggestions - Code explanations and documentation generation - Integration with local LLMs

- Official JetBrains plugin - Seamless integration with IntelliJ IDEA - May have limited customization compared to open-source alternatives
]]>
<![CDATA[Part 2: Backend Setup with NestJS]]> https://blog.lunatech.com/posts/2025-04-22-part-2-backend-setup-with-nestjs/ https://blog.lunatech.com/posts/2025-04-22-part-2-backend-setup-with-nestjs/ Tue, 22 Apr 2025 00:00:00 +0000

With our project vision and stack laid out in Part 1, it’s time to build the backend. In this part, we’ll scaffold a NestJS project, connect it to PostgreSQLvia TypeORM, and implement secure JWT-based authentication.

By the end of this part, you’ll have:

  • A working NestJS app connected to PostgreSQL

  • A User entity with hashed passwords

  • Endpoints for registration and login

  • JWT authentication using guards and strategies

  • CORS enabled to allow Angular frontend communication

'''''

Project Structure Overview

Once set up, your backend folder will look like this:

src/
├── app.module.ts
├── main.ts
├── auth/
│   ├── auth.controller.ts
│   ├── auth.module.ts
│   ├── auth.service.ts
│   ├── dto/
│   │   ├── login.dto.ts
│   │   └── register.dto.ts
│   └── jwt/
│       └── jwt.strategy.ts
├── users/
│   ├── user.entity.ts
│   ├── users.controller.ts
│   ├── users.module.ts
│   └── users.service.ts
.env

'''''

Goals for Part 2

  1. Scaffold a NestJS project with PostgreSQL and TypeORM

  2. Create a User entity, DTOs with validation, and an Auth module

  3. Implement registration and login with password hashing

  4. Add JWT authentication with guards and strategy

  5. Enable CORS for frontend connectivity

'''''

Step 1: Run PostgreSQL and Set Environment Variables

You can use either Docker or Podman. Run one of the following commands in your terminal:

Docker:
docker run --name pg-nest \
  -e POSTGRES_USER=nestuser \
  -e POSTGRES_PASSWORD=nestpass \
  -e POSTGRES_DB=nestdb \
  -p 5432:5432 \
  -d postgres
Podman:
podman run --name pg-nest \
  -e POSTGRES_USER=nestuser \
  -e POSTGRES_PASSWORD=nestpass \
  -e POSTGRES_DB=nestdb \
  -p 5432:5432 \
  -d postgres

Once the project is created in Step 2, add a .env file to your root:

DB_HOST=localhost DB_PORT=5432 DB_USERNAME=nestuser DB_PASSWORD=nestpass
DB_NAME=nestdb JWT_SECRET=dev_secret BCRYPT_SALT_ROUNDS=10

'''''

Step 2: Scaffold the Project and Install Dependencies

nest new backend
cd backend

Install project dependencies:

npm install @nestjs/typeorm typeorm pg @nestjs/jwt passport-jwt @nestjs/passport bcrypt class-validator class-transformer dotenv

At the top of main.ts, import environment variables:

import "dotenv/config";

Enable CORS:

app.enableCors({
  origin: "http://localhost:4200",
  credentials: true,
});

Tip: For production, consider using @nestjs/config for cleaner environment management.

'''''

Step 3: Configure TypeORM

In app.module.ts:

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "postgres",
      host: process.env.DB_HOST,
      port: parseInt(process.env.DB_PORT, 10),
      username: process.env.DB_USERNAME,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      entities: [__dirname + "/**/*.entity{.ts,.js}"],
      synchronize: true, // ⚠️ Don't use this in production — it'll drop/recreate tables
    }),
    UsersModule,
    AuthModule,
  ],
})
export class AppModule {}

'''''

Step 4: Create the User Module and Entity

Generate boilerplate:

nest g module users
nest g service users
nest g controller users

In users/user.entity.ts:

import { Exclude } from "class-transformer";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;

  @Column()
  @Exclude()
  password: string;

  @Column()
  role: string;
}

'''''

Step 5: Set Up the Auth Module

Generate files:

nest g module auth
nest g service auth
nest g controller auth

Step 5a: Create DTOs with Validation

In auth/dto/register.dto.ts:

import { IsEmail, IsNotEmpty, MinLength } from "class-validator";

export class RegisterDto {
  @IsEmail()
  email: string;

  @MinLength(6)
  password: string;

  @IsNotEmpty()
  name: string;

  @IsNotEmpty()
  role: string;
}

These are like your request payload models with annotations. Think @NotEmpty, @Email, etc. The validation logic is handled globally (we’ll wire that up in main.tsusing ValidationPipe).

In auth/dto/login.dto.ts:

import { IsEmail, MinLength } from "class-validator";

export class LoginDto {
  @IsEmail()
  email: string;

  @MinLength(6)
  password: string;
}

Enable validation globally in main.ts:

import { ValidationPipe } from "@nestjs/common";

app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

'''''

Step 6: Implement UsersService

This service handles persistence logic using TypeORM’s repository pattern. In users.service.ts:

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "./user.entity";

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private repo: Repository<User>
  ) {}

  create(data: Partial<User>) {
    const user = this.repo.create(data);
    return this.repo.save(user);
  }

  findByEmail(email: string) {
    return this.repo.findOne({ where: { email } });
  }
}

Again, if you’re used to JPA, this is just standard repository stuff.

In users.module.ts:

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { UsersService } from "./users.service";
import { UsersController } from "./users.controller";

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  controllers: [UsersController],
  exports: [UsersService],
})
export class UsersModule {}

'''''

Step 7: Build AuthService

This is where registration and login happens. We hash passwords with bcrypt, and return a JWT if login succeeds.

You’ll notice:

delete user.password;

It manually removes the password before returning the user — feels a bit hacky, but we’ve also used @Exclude() in the entity, so this is just being extra cautious.

⚠️ We’re using both @Exclude() (to hide the password when transforming entities) and delete user.password as a backup. Depending on how Nest returns the object — directly vs. through a serialization step — the password field might still leak through without this extra guard.

In auth.service.ts:

import { Injectable, UnauthorizedException } from "@nestjs/common";
import * as bcrypt from "bcrypt";
import { JwtService } from "@nestjs/jwt";
import { UsersService } from "../users/users.service";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async register(dto: RegisterDto) {
    const existing = await this.usersService.findByEmail(dto.email);
    if (existing) throw new UnauthorizedException("Email already in use");

    const hashed = await bcrypt.hash(dto.password, 10);
    const user = await this.usersService.create({
      ...dto,
      password: hashed,
    });
    delete user.password;
    return user;
  }

  async login(dto: LoginDto) {
    const user = await this.usersService.findByEmail(dto.email);
    const valid = user && (await bcrypt.compare(dto.password, user.password));
    if (!valid) throw new UnauthorizedException("Invalid credentials");

    const payload = { sub: user.id, role: user.role };
    return { access_token: this.jwtService.sign(payload) };
  }
}

In auth.module.ts:

import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { UsersModule } from "../users/users.module";
import { JwtStrategy } from "./jwt/jwt.strategy";

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: "1d" },
    }),
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

'''''

Step 8: JWT Strategy and Guards

Here we set up Passport’s JWT strategy. If you’re new to Passport: it’s just NestJS’s way of plugging in different auth strategies.

Create the strategy file:

touch src/auth/jwt/jwt.strategy.ts

In jwt.strategy.ts:

import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET,
    });
  }

  validate(payload: any) {
    return { id: payload.sub, role: payload.role };
  }
}

This function runs once the JWT is verified. It attaches the returned object to req.user.

So if you hit a route with a valid JWT, this is what gets injected.

'''''

Step 9: Connect Auth Routes

In auth.controller.ts:

import { Controller, Post, Body } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";

@Controller("auth")
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post("register")
  register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }

  @Post("login")
  login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }
}

Two routes here:

  • POST /auth/register → Creates a user

  • POST /auth/login → Validates user and returns a token

Nice and clean.

'''''

Step 10: Test It

Start your server:

npm run start:dev

Use Postman or Insomnia:

Register

POST /auth/register
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "123456",
  "name": "Test User",
  "role": "user"
}

Login

POST /auth/login
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "123456"
}

Response:

{
  "access_token": "<JWT_TOKEN>"
}

Use this token to access protected routes with:

Authorization: Bearer <JWT_TOKEN>

'''''

Recap

You now have:

  • A functional NestJS backend with PostgreSQL

  • User registration and login with secure hashed passwords

  • JWT-based authentication strategy and guards

  • DTO validation and global pipes

  • Environment config and CORS enabled for the frontend

👉 *Next up (Part 3): We’ll switch gears and start building the Angular frontend — hooking it up to this backend, wiring in JWT auth, and securing client-side routes.*

]]>
<![CDATA[GenAI: Optimizing Local Large Language Models Performance]]> https://blog.lunatech.com/posts/2025-04-17-genai-optimizing-local-large-language-models-performance/ https://blog.lunatech.com/posts/2025-04-17-genai-optimizing-local-large-language-models-performance/ Thu, 17 Apr 2025 00:00:00 +0000

Local large language models (LLMs) are becoming more capable and accessible than ever — but to truly unlock their power, you need to know how to optimize them. Whether you're running AI on an M-series MacBook or a 2019 Intel machine, there are tricks to get better speed, quality, and control.

In the final session of our GenAI series (after covering Local Dev, RAG, and Agents), we explored the real-world performance tuning of local LLMs. Here's what we covered 👇

1. Running LLMs on Your Laptop

Apple Silicon vs Intel Macs

Feature Apple Silicon (M1/M2/M3/M4) Intel Mac (e.g. 2019 MBP)
CPU & GPU Unified on one chip (SoC) Separate chips for CPU and GPU
Memory Shared Unified Memory Architecture RAM for CPU, VRAM for GPU
Model performance Fast, efficient, great for LLMs Slower, GPU mostly unused
NOTE

M-series chips shine for local AI workloads thanks to unified memory.Intel Macs can still work well but rely entirely on CPUs and may struggle with larger models.

What Can You Run?

Models are measured by parameters, typically in billions (e.g., 3B, 7B, 13B). More parameters = more "intelligence" — but also more memory use.

Use quantization (more below!) to fit larger models, even on machines with 8–16 GB RAM.

2. Quantization: Making LLMs Lighter

What is Quantization?

Quantization reduces the precision of the numbers in a model — trading a little accuracy for huge gains in size and speed.

Format Description Typical Use
FP32 Full precision Training
FP16 Half precision Deployment
INT8 Quantized Light inference (some loss)
INT4 Heavily quantized Fast local inference
INT2–3 Experimental Ultra-lightweight, niche

LLaMA2-70B goes from 138GB (FP16) to 42GB (INT4) — a huge saving!

What is GGUF?

GGUF stands for GPT-Generated Unified Format. It’s a modern, standardized file format designed for running quantized large language models (LLMs) locally .

Why GGUF?

GGUF makes loading and using models in tools like llama.cpp, Ollama, or LM Studio.

Feature Benefit
🧳 Compact Stores quantized models in a smaller, efficient format
🧠 Self-contained Includes weights, tokenizer, metadata, and vocab in one file
⚡ Fast Optimized for quick loading and inference
🔧 Flexible

Supports various quantization formats like Q4_K_M, Q5_K_S, etc.

Tools that Support GGUF

  • llama.cpp – Core backend for CPU/GPU inference

  • Ollama – CLI + API for local models

  • LM Studio – Desktop GUI for Mac/Windows/Linux

  • text-generation-webui – Powerful browser-based frontend

Example GGUF Model Filename

mistral-7b-instruct-v0.1.Q4_K_M.gguf
Part Description

mistral-7b-instruct

Model type and size

v0.1

Model version

Q4_K_M

Quantization type (4-bit, medium group)

.gguf

File format extension

Think of a .gguf file as an AI-in-a-box — it includes everything the model needs to run locally, compressed and ready to go.

K-Quant Types (for GGUF Models)

When downloading quantized models (especially in GGUF format), you'll often see suffixes like Q4_K_S, Q5_K_M, or Q6_K_L. These aren't just random labels — they define how the quantization is applied , and they directly affect model quality vs. performance .

The K in these names refers to the quantization group size and method , which influences:

  • how much precision is preserved;

  • how fast the model can run;

  • how much RAM is required.

Suffix Description

K_S

Small group quantization Fastest and most memory-efficient. It compresses more aggressively, which means it's ideal when you're on a tight memory budget or running on weaker hardware — but output quality might noticeably degrade on complex tasks.

K_M

Medium group quantization A great balance of speed, memory use, and output quality. It's the "safe middle ground" for most users running 4–7B models locally. If you're unsure where to start, this is a solid default.

K_L

Large group quantization More conservative compression — keeps more precision, giving higher-quality outputs. Slower and uses more RAM, but closer to non-quantized behavior. Ideal for tasks requiring accurate and nuanced responses.

The difference isn't just in speed — it's also in how much subtle detail the model retains in its responses. // ====

“Think of K_S, K_M, and K_L as image compression presets:Small is low-res JPEG, Medium is standard HD, Large is almost RAW quality.”

What is Quantization used for?

Quantization is a trade-off between size, speed, and accuracy. For some perspective, here’s a rough guide on how quantization is used in the LLM world:

Task Quantization
Model training FP32 (that's why it is so expensive to train models, to get that knowledge accurate)
For deployment Usually FP16 (for speed)
Local models Unused INT4 (for speed and accuracy)

Why Not Just Use a Smaller Model?

  • Models under 3B can run easily — but often lack reasoning or language nuance.

  • Quantization gives you the best of both worlds: keep a 7B+ model’s brain but shrink the size.

“It’s like watching a 4K movie compressed to 1080p — smaller, still looks good.”

Quantized Models In Action (Example)

In this example, we consider qwen2.5 , a 14B model with quantized versions available in Ollama. We will focus on different quantization levels of the 14B model.

Let's have a look at how different models deal with the following prompt:

Explain recursion to a 10-year-old in one paragraph.

To run the models, execute one of the following prompts, starting with Q4_0:

ollama run qwen2.5:14b-instruct-q4_0
ollama run qwen2.5:14b-instruct-q8_0
ollama run qwen2.5:14b-instruct-q2_K
ollama run qwen2.5:14b-instruct-fp16

For example, let's start with Q4_0:

ollama run qwen2.5:14b-instruct-q4_0

Now that the model is loaded, we can run the prompt:

"Explain recursion to a 10-year-old in one paragraph."

Pay attention to the response time and the memory usage. Compare it with the other models.

You may have noticed there is not much difference in quality between the Q40 and Q80 models, but the Q2_K model is much faster and smaller. Perfect for showing the quality/speed trade-off in action, and how to adjust for your needs. This does not necessarily mean that the behavior is the same for other prompts or tasks. You have to try this for yourself and see what works best for you on your machine.

3. Tuning Parameters in Ollama

Using Ollama? You can change some parameter settings of the local models based on your preference, for example, a more deterministic response, or a more creative one.

Let us consider the llama3 model, we can run it with the following command:

ollama run llama3

You can set these parameters after the model is loaded:

/set parameter <parameter> <value>

So, for example, to set temperature to 1.0:

/set parameter temperature 1.0

And then we set top_p to 0.9:

/set parameter top_p 0.9

In the example above, we set the temperature to 1.0, a more creative response, and top_p to 0.9, a more deterministic response. Parameter temperature adds randomness. The lower the value, the more focused and deterministic the model response. The higher the value, the more creative and varied the response. Parameter top_p picks from the smallest possible set of words whose cumulative probability adds up to p. It controls diversity — higher values mean more diverse and creative responses, and lower values make responses more focused.

Here are some more common parameters you can tune:

Param What it Does Typical Values
num_ctx Context size (how much it remembers) 2048–4096
top_k Limits top options for output 40–100
top_p Controls diversity 0.8–0.95
temperature Controls creativity 0.6–0.8 (chat), 0.3–0.6 (code)
repeat_penalty Avoids repeating phrases 1.1–1.3
threads Number of CPU threads (config only) Match to physical cores

If you want to learn more about the parameters, you can find some extra information here .

Final Thoughts

Running LLMs locally is no longer science fiction — it's practical, efficient, and private. With just a bit of tuning and the right model format, your laptop becomes an AI powerhouse.

]]>
<![CDATA[Full-Stack Authentication Boilerplate: Angular + NestJS + PostgreSQL]]> https://blog.lunatech.com/posts/2025-04-04-full-stack-authentication-boilerplate-angular-nestjs-postgresql/ https://blog.lunatech.com/posts/2025-04-04-full-stack-authentication-boilerplate-angular-nestjs-postgresql/ Fri, 04 Apr 2025 00:00:00 +0000

A practical series for backend developers building secure full-stack apps

For backend developers who want to ship full-stack projects—without wrestling with frontend complexity.

We’re building a full-stack authentication boilerplate —a reusable, modular codebase that gives you login, registration, route protection, token handling, and database integration out of the box. Clone it, customize it, and start building faster.

Why This Stack?

We chose Angular 19 and NestJS for a reason:

  • TypeScript end-to-end : Write frontend and backend in the same language with consistent tooling.

  • Angular 19 introduces Signals and control flow syntax (@if, @for), simplifying state and UI logic.

  • NestJS is structured, modular, and test-friendly—ideal for developers coming from Spring or .NET.

  • PostgreSQL + TypeORM provide a robust relational layer with strong typing and migrations.

You could build this with React, Express, or MongoDB. But we picked this stack for its structure and scalability—great for teams or solo developers who want a solid foundation, not just flexibility.

What’s in the Series?

Here’s the roadmap 👇

Roadmap overview

Each post builds on the last, walking you through:

  • ✅ JWT login & registration

  • ✅ Angular route guards & auth state

  • ✅ NestJS modules, guards, and services

  • ✅ PostgreSQL setup with TypeORM

  • ✅ CI/CD, E2E tests with Playwright, and Docker deployment

You don’t need prior Angular or NestJS experience—just TypeScript and backend fundamentals.

Why This Series Exists

You don’t need another to-do app.

You need a working authentication foundation —something you can reuse, extend, and deploy. That’s what this series delivers, while helping you build real frontend + backend skills along the way.

Let’s build something you can actually ship.

]]>
<![CDATA[Part 1: Introduction and Stack Breakdown for the Angular + NestJS Auth Boilerplate]]> https://blog.lunatech.com/posts/2025-04-04-part-1-introduction-stack-breakdown-for-the-angular-nestjs-auth-boilerplate/ https://blog.lunatech.com/posts/2025-04-04-part-1-introduction-stack-breakdown-for-the-angular-nestjs-auth-boilerplate/ Fri, 04 Apr 2025 00:00:00 +0000

Welcome to the first part in this series on building a full-stack authentication boilerplate with Angular, NestJS, and PostgreSQL.

If you’re a backend developer comfortable with APIs, databases, and business logic—but frontend frameworks feel like foreign territory—this series is for you. We’ll walk through the full stack, showing you how to build real authentication flows while picking up modern Angular features along the way.

By the end, you’ll have a production-ready authentication boilerplate you can reuse in your own projects—or hand off confidently to frontend collaborators.

Why Angular + NestJS?

We’re using Angular on the frontend and NestJS on the backend. Both are built in TypeScript, so you get a unified development experience without switching mental models.

Here’s why this stack works well together:

  • Angular is opinionated, scalable, and modular—perfect for building real apps, not just prototypes. Angular 19 introduces useful features like Signals and new control flow syntax that cut boilerplate and improve performance.

  • NestJS is a structured backend framework with decorators, guards, and strong support for things like JWTs and PostgreSQL—ideal for secure authentication systems.

  • PostgreSQL is a reliable, battle-tested relational database with great tooling and support for user/session management.

Together, this stack gives you a clean, testable, and scalable foundation—without gluing together a bunch of libraries yourself.

What You’ll Build

Over this series, you’ll create a modular, full-stack authentication boilerplate with:

  • A NestJS backend featuring JWT login and registration

  • A styled Angular frontend with auth forms and route guards

  • Token management, global error handling, and frontend state control

  • CI/CD pipelines, testing setup, and a containerized dev environment

This isn’t a toy app—it’s a starting point for real projects.

What’s New in Angular 19

Angular 19 introduces major improvements that simplify frontend development and reduce complexity:

  • Signals – A new reactive state primitive. Think of it like +useState+, but built into Angular. Great for managing form state and authentication status.

  • Control Flow Syntax+@if+, +@for+, and +@switch+ clean up your templates and reduce the need for verbose structural directives.

  • Defer Blocks – Lazily load components based on app state, perfect for gating authenticated vs. public views.

  • Improved Change Detection – Smarter re-rendering out of the box for better performance.

We’ll use these features where they make sense—especially in the login and registration flows—so you learn by doing.

Who This Is For

This series is for backend developers who want to:

  • Understand modern Angular concepts without getting lost in frontend jargon

  • Build secure, token-based authentication flows using best practices

  • Deploy real full-stack apps with confidence

You don’t need Angular experience. If you know TypeScript, you’ll feel at home.

Tools You’ll Need

Required

  • Node.js – Backend + Angular CLI

  • NestJS CLI – Backend scaffolding

  • Angular CLI – Frontend scaffolding

  • PostgreSQL – Local dev database

  • Git – Version control

Optional (but helpful)

  • Postman – For testing APIs

  • pgAdmin – For inspecting the database

  • VS Code – With TypeScript extensions

Getting Set Up

  1. Install Node.js
    https://nodejs.org
    node -v
    npm -v
  2. Install Angular and NestJS CLIs
    npm install -g @angular/cli @nestjs/cli
  3. Install PostgreSQL
    https://www.postgresql.org/download
    CREATE DATABASE auth_boilerplate;
  4. Initialize Git Repo
    git init

Coming Up Next (Part 2)

In Part 2, we’ll scaffold the NestJS backend, hook it up to PostgreSQL with TypeORM, and implement JWT-based login and registration logic you can actually ship.

Final Thoughts

This series is about building something real—not another tutorial app you toss out after reading. Whether it’s for a SaaS product, side project, or production tool, having a solid authentication foundation makes every other feature easier to build.

]]>
<![CDATA[Mastering Typeclass Derivation with Scala 3]]> https://blog.lunatech.com/posts/2025-03-07-typeclass-derivation/ https://blog.lunatech.com/posts/2025-03-07-typeclass-derivation/ Fri, 07 Mar 2025 00:00:00 +0000

Polymorphism

When learning about programming we stumble upon the concept of Polymorphism, and when learning something new I always wonder, why should we care? Besides getting a new tool on our tool belt I think it's important to understand what are we getting from this new programming trick.

Polymorphism, from the Greek "having multiple forms", allows us to define and implement a set of different algorithms using the same interface. This has the benefit of lowering the complexity of our system from our client's point of view. By switching to another implementation, they don't need to understand what changed underneath, they just need to know that the interface is honored by another implementation. And by lowering this complexity we lower the cognitive-load developers need to have while using our API, making their code easier to maintain.

As far as I'm aware, there are at least 3 types of polymorphism:

  • Ad-Hoc

  • Parametric, and

  • Sub-type

If you come from a Object Oriented programming background you'll recognize the last one. Sub-type polymorphism is implemented by defining a parent class that defines an API, and child classes implementing it. Then using a mechanism like double-dispatch, a implementation is chosen based on the actual subclass being instantiated:

trait Shape:
    def area: Double

class Square(width: Double) extends Shape:
    override def area: Double = width * width

class Rectangle(width: Double, height: Double) extends Shape:
    override def area: Double = width * height

class Circle(radius: Double) extends Shape:
    override def area: Double = Math.pow(radius, 2) * Math.PI

val s1: Shape = new Circle(4)
val s2: Shape = new Rectangle(4, 2)

s1.area
s2.area

Another type of polymorphism we can find in both Functional and Object Oriented programming languages is Parametric polymorphism where we don't necessarily provide different implementations based on different types, but rather abstract over some types:

def identity[A](a: A): A = a

enum List[+A]:
    case Cons(head: A, tail: List[A]) extends List[A]
    case Nil extends List[Nothing]

    def map[B](fn: A => B): List[B] = this match
        case Cons(head, tail) => Cons(fn(head), tail.map(fn))
        case Nil => Nil

The List doesn't care what's containing, but its map method can easily work for Ints, Strings, and so on.

The last type of polymorphism we mentioned was Ad-Hoc polymorphism, which can be defined as a "A system where functions or expressions are defined for specific types". Let's see how this gets implemented using Typeclasses.

Typeclasses

Typeclasses are a way to implement Ad-Hoc polymorphism with 2 significant properties:

  • Separate Definitions: For each relevant class, we define a separate instance of a function or method (like Show, Eq, etc.) This similar to defining a separate function for specific types.

  • Context-Dependent Selection: An implementation will be selected based on where the typeclass is used.

In Scala, a Typeclass is implemented by defining 3 parts:

  • Interface: What functionality is the Typeclass offering.

  • Instances: How is that functionality implemented.

  • Usage: Where and how is that functionality used.

Let's see a simple implementation of the Show Typeclass:

// Interface
trait Show[A]:
    def show(a: A): String

// Instances
object Show:
    given Show[Int]     = (i: Int) => i.toString
    given Show[String]  = (s: String) => "'" + s + "'"
    given Show[Boolean] = (b: Boolean) => i.toString

// Usage
def log[A](a: A)(using s: Show[A]): Unit =
    println(s.show(a))

An important detail of the Typeclass definition is that we use a Type Parameter on it's interface, instances, and usage.

Typeclass Derivation

An interesting feature of Scala is that we can automatically generate the instances of a Typeclass by implementing Typeclass Derivation code. This will make the compiler generate instances at compile-time, making our Typeclass more usable as we'll shift the burden of implementation to our code instead of our client's code.

To derive instances for a Typeclass we need:

  • Tools to decompose complex types, such as case classes.

  • Tools to compose complex types from more simpler types.

And one way we can approach implementing Derivation code is:

  • Think Recursively (Base vs. Iterative Case)

  • Implementing one Typeclass by implementing a simpler version first.

On Scala 2 we'd use a library like Shapeless or Magnolia to compose and decompose complex types. In Scala 3 these features are backed in the language.

Mirrors provide typelevel information about types being derived, with similar features as HList andCoproduct in a single abstraction.

import scala.collection.AbstractIterable
import scala.compiletime.{erasedValue, error, summonInline}
import scala.deriving.*

// Interface
trait Show[A]:
    def show(a: A): String

// Instances
object Show:
    given Show[Int]     = (i: Int) => i.toString
    given Show[String]  = (s: String) => "'" + s + "'"
    given Show[Boolean] = (b: Boolean) => i.toString

    def iterable[T](p: T): Iterable[Any] = new AbstractIterable[Any]:
        def iterator: Iterator[Any] = p.asInstanceOf[Product].productIterator

    def showProduct[T](p: Mirror.ProductOf[T], elems: => List[Show[?]]): Show[T] =
        new Show[T]:
            def show(a: A): String =
                iterable(x).lazyZip(elems).map { case (i, s) => s.show(i) }.mkString(",")

    inline def derived[A](using m: Mirror.ProductOf[A]): Show[A] =
        lazy val elemInstances = summonInstances[T, m.MirroredElemTypes]
        showProduct(m, elemInstances)

    inline def summonInstances[T, Elems <: Tuple]: List[Show[?]] =
        inline erasedValue[Elems] match
            case _: (elem *: elems) => deriveOrSummon[T, elem] :: summonInstances[T, elems]
            case _: EmptyTuple => Nil

    inline def deriveOrSummon[T, Elem]: Show[Elem] =
        inline erasedValue[Elem] match
            case _: T => deriveRec[T, Elem]
            case _    => summonInline[Show[Elem]]

    inline def deriveRec[T, Elem]: Show[Elem] =
        inline erasedValue[T] match
            case _: Elem => error("infinite recursive derivation")
            case _       => Show.derived[Elem](using summonInline[Mirror.Of[Elem]]) // recursive derivation

This is a simplified example of a Typeclass Derivation for the Show Typeclass. For a more thorough example with a more detailed explanation, I cannot recommend Type Class Derivation enough.

]]>
<![CDATA[The Scala effect: Java’s Evolution Inspired by Scala]]> https://blog.lunatech.com/posts/2025-02-28-the-scala-effect/ https://blog.lunatech.com/posts/2025-02-28-the-scala-effect/ Fri, 28 Feb 2025 00:00:00 +0000

Contents

Since its inception, Java has been one of the most widely used programming languages, known for its stability and enterprise adoption. However, as modern software development demands more expressive and concise code, Java has often looked to other languages for inspiration. One of its biggest influences has been Scala—a language that brought functional programming, immutability, and expressive syntax to the JVM.

Over the years, Java has steadily incorporated many features that were first introduced in Scala, from lambda expressions and pattern matching to records and data-oriented programming. In this article, we’ll explore how Java has evolved by adopting concepts from Scala, breaking down the changes version by version. We'll also see some examples, to see each language's unique approach, while pointing out some key differences.

Java 8

Stream API

I don't think I need to introduce many of the readers to the Stream API, born in Java 8. It is also well known, how it basically changed the game on how you approach operations on collections. What might be a bit less known is the fact, that this feature is a big step towards functional programming, since the API promotes functional paradigms, such as immutability, pure functions, higher level functions. Exploring those paradigms could use a talk in itself, so the below example is rather to show, from a developer experience perspective how easy it is to do multiple, small operations on a collection, with the new API compered to the old way.

Scala Java (old) Java (new)
def getNamesOfMammals(animals: List[Animal]): List[String] =
animals
.filter(_.classification == "mammal")
.map(_.name)
private List<String> getNamesOfMammals(List<Animal> animals) {
List<String> names = new ArrayList<>();
for (Animal animal: animals) {if (animal.classification.equals("mammal")) {names.add(animal.name);}}return names;}
private List<String> getNamesOfMammals(List<Animal> animals) {
return animals
.stream()
.filter(animal -> animal.classification.equals("mammal"))
.map(animal -> animal.name)
.toList();
}

It is worth to note, that the above example is nice, and already a huge step in the right direction, when it comes to streaming data processing, java still lacks support for a lot of major operations (fold, sliding windows).

Optional

Safety first! Especially when it comes to nulls, and java. Option is a core part of the Scala language, making nullable data a breeze to work with, through great support for operations, and branchless behaviour definition. Java intends to implement some of that functionality with the Optional class. Just like in the streams' case, scala still offers more flexibility and more defined developer options, when it comes to potentially missing data (e.g.: exists, forall). The example illustrates the boilerplate needed to handle nested nullable structures, and the positive steps java already took in the right direction.

Scala Java (old) Java (new)
case class Wing(length: Int, fact: Option[String])
case class Animal(name: String, wing: Option[Wing])

def printSafeInterestingWingFacts() =

animals.foreach : ${animal.wing
class Wing{public Integer length; public String fact;}
class Animal{public String name; public Wing wing;}
private void printInterestingWingFacts(List<Animal> animals) {for (Animal animal: animals) {if (animal.wing != null) {if (animal.wing.fact != null) {System.out.printf("%s: %s%n", animal.name, animal.wing.fact);} else {System.out.printf("%s: no interesting wing fact :(%n", animal.name);}} else {System.out.printf("%s: no interesting wing fact :(%n", animal.name);}}}
class Wing{public Integer length; public Optional<String> fact;}
class Animal{public String name; public Optional<Wing> wing;}
private void printSafeInterestingWingFacts(List<Animal> animals) {animals.forEach(animal -> {System.out.printf("%s: %s%n", animal.name, animal.wing});}

Functional interfaces (lambdas)

One of the key prerequisites of having support for all these functional paradigms, is to be able to pass functions as arguments. Java implements this concept via so-called functional interfaces. A functional interface, is an interface, with only one method declared. Java 8 also makes it easier to use these interfaces with the lambda syntax. With the lambda syntax you can pass lambda functions as arguments, and the compiler will be able to interpret it, as the needed interface.

Scala Java (old) Java (new)
def chainEvenNumbers(numbers: List[Int]): String =
numbers
.filter(n => n % 2 == 0)
.map(n => n.toString)
.reduce{case (n1, n2) => n1 + n2}
String chainEvenNumbers(List<Integer> numbers) {
Predicate<Integer> predicate = new Predicate<>() {
@Override
public boolean test(Integer integer) {
return integer % 2 == 0;
}
};
Function<Integer, String> mapper = new Function<>() {@Overridepublic String apply(Integer integer) {return integer.toString();}};BinaryOperator<String> accumulator = new BinaryOperator<>() {@Overridepublic String apply(String s, String s2) {return s + s2;}};return numbers}
String chainEvenNumbers(List<Integer> numbers) {
return numbers
.stream()
.filter(i -> i % 2 == 0)
.map(Object::toString)
.reduce("", (s, s2) -> s + s2);
}

Java 11

Java 11's main features fall outside of this article's scope, since they are not directly influenced by Scala, nor resemble existing Scala features. I would like to mention however a couple small additions, mainly in the area of scripting, and writing / running fast, without overhead. OVer the years a good argument for learning Scala (or against learning java, really) was that for a beginner, the learning curve might be steep, and writing small code snippets is not really possible. On top of that, while it scales up excellent, it is not really ideal for small projects. The following additions you can argue how much they were inspired by Scala actually (I wouldn't argue against it either), but one thing is not arguable: Scala was always ahead in these topics, and with 11, Java is now one step closer.

_<Collection>_::of factory method (Java 9)

No more dubious initialization, and element adding after, with Java 11 now most of the commonly used collections receive the ::of factory method, where you can quickly create a new immutable instance, with a number of initial elements.

Scala Java (old) Java (new)
val l: List[Int] = List(1, 2, 3, 4, 5)
List<Integer> l = new ArrayList<>();
l.add(1);l.add(2);l.add(3);l.add(4);l.add(5);
List<Integer> l = List.of(1, 2, 3, 4, 5);

Introduction of _jshell_ (java REPL) (Java 9)

Huge step towards scripting, but also really important for bigger projects. The presence of the jshell is equally amazing for absolute beginners, as well as seasoned developers. It could be used in various ways, from writing quick "hello world" code to quickly test out more serious code in a bigger production environment. The java REPL is not yet that advanced as the Scala counterpart, but this long overdue feature is again making the language a bit more consumable.

Addition of var keyword (Java 11)

If it was not a 100% clear what does it mean, when we talk about steep learning curve for beginners, or challenges to test out code segment quickly in a bigger environment, having to quickly write a code like this (in both cases) hopefully explains it:

void testStateAndTranslator() {
    InternalFrameInternalFrameTitlePaneInternalFrameTitlePaneMaximizeButtonWindowNotFocusedState state = nimbus.getState();
    assert state == expectedState;
    RefreshAuthorizationPolicyProtocolServerSideTranslatorPB translator = hadoop.getTranslator();
    assert translator == expectedTranslator;
}

Now this is a test, where you would most likely get some autocorrection, but imagine the nightmare typing these in the REPL! Luckily if you want to quicly test out something like this, sice java 11 it would only look like something like this:

jshell> var state = nimbus.getState()
state ==> InternalFrameInternalFrameTitlePaneInternalFrameT ... owNotFocusedState@1a86f2f1

jshell> state.testStuff()
Stuff works!

Launch single file (Java 11)

This is all great so far, but what if you want to write some code quickly to generate input, or want to process a file? You wouldn't want to include that file in your project, and definitely don't want to recompile every time you tweak thatprintln statement. With Java 11, you get another benefit which points java in the scripting direction. You can run single java files, without associating it to a project, or even without precompiling it. In fact you can include a she-bang line, and run it as a shell script!

❯ javac HelloWorldJava8.java
❯ java HelloWorldJava8
Hello World!
❯ java HelloWorldJava11.java
Hello World!
❯ ./HelloWorldJava11Shell.java
Hello World!

Java 17

Switch expressions (Java 14)

Switch expressions' functionality is still limited to the same restrictions as before (we are going to talk about these restrictions later), but it got a nice facelift, where you can leave a lot of boilerplate code behind!

Scala Java (old) Java (new)
def kindergartenToString(number: Int): String =
number match {
case 1 => "1"
case 2 => "2"
case _ => "cannot count until that"
}
private String kindergartenToString(Integer number) {
String result;
switch (number) {
case 1:
result = "1";
break;
case 2:
result = "2";
break;
default:
result = "cannot count until that";
};
return result;}
private String kindergartenToString(Integer number) {
return switch (number) {
case 1 -> "1";
case 2 -> "2";
default -> "cannot count until that";
};
}

Text blocks (Java 15)

Another long overdue feature, where you wonder, why exactly did we need to wait until Java 15 for that?

Scala Java (old) Java (new)
var sql =
"""
\|SELECT
\|  name,
\|  salary,
\|  title
\|FROM
\|  employees
\|WHERE
\|  age < 25
\|  AND title in (
\|    SELECT
\|      summary
\|    FROM
\|      jobs
\|  )
\|""".stripMarging
String sql = "SELECT\n" +
"  name,\n" +
"  salary,\n" +
"  title\n" +
"FROM\n" +
"  employees\n" +
"WHERE\n" +
"  age < 25\n" +
"  AND title in (\n" +
"    SELECT\n" +
"      summary\n" +
"    FROM\n" +
"      jobs\n" +
"  )";
String sql = """
SELECT
name,
salary,
title
FROM
employees
WHERE
age < 25
AND title in (
SELECT
summary
FROM
jobs
)""";

Pattern matching instanceof (Java 16)

You can argue if this in itself is a huge step or not, but this definitely set the table, for what's to come with Java 21. And not having to cast (although it is 100% safe, already from context) by hand every time, and having to have either multiple casts, or nested branches is already a big step, but let the code speak for itself.

Scala Java (old) Java (new)
def callIfPositiveInt(any: Any): Unit =
any match {
case i: Int => i.someMethodOnInt()
}
void callIfPositiveIntImpl1(Object object) {
if (object instanceof Integer) {
Integer i = (Integer) object;
if (i > 0) {i.someMethodOnInteger();}}}void callIfPositiveIntImpl2(Object object) {if (object instanceof Integer && ((Integer) object) > 0) {Integer i = (Integer) object;i.someMethodOnInteger();}}
void callIfPositiveInt(Object object) {
if (object instanceof Integer i && i > 0) {
i.someMethodOnInteger();
}
}

Record classes (Java 16)

Record classes are a new type declaration, that allows you to compactly define immutable data classes. They are intended to be transparent carriers of their shallowly immutable data.

They resemble the well known case class in Scala. There's no such thing as "same" between two languages, so it goes without saying that they have (on top of the many similarities) some differences. Exactly discovering all the similarities and differences is outside of the scope of this article, but from a developer experience perspective, being able to write compact code that speaks for itself is the same for both.

Records are going to be even more powerful in Java 21, paired with pattern matching concepts.

Scala Java (old) Java (new)
case class Point(x: Int, y: Int)
val point = Point(1, 2)val x = point.xval y = point.yval hashCode = point.hashCodeval s = point.toString // "Point[x=1, y=2]"assert(point == Point(1, 2))assert(!(point == Point(2, 2)))
class Point {private final Integer x;private final Integer y;public Point(Integer x, Integer y) {this.x = x;this.y = y;}public Integer x() {return x;}public Integer y() {return y;}@Overridepublic String toString() {return String.format("Point[x=%d, y=%d]", x, y);}@Overridepublic boolean equals(Object object) {if (this == object) return true;if (!(object instanceof Point point)) return false;return Objects.equals(x, point.x) && Objects.equals(y, point.y);}@Overridepublic int hashCode() {return Objects.hash(x, y);}}Point point = new Point(1, 2);Integer x = point.x();Integer y = point.y();int hashCode = point.hashCode();String s = point.toString(); // "Point[x=1, y=2]"assert point.equals(new Point(1, 2));assert !point.equals(new Point(2, 2));
record Point(Integer x, Integer y) {}
Point point = new Point(1, 2);Integer x = point.x();Integer y = point.y();int hashCode = point.hashCode();String s = point.toString(); // "Point[x=1, y=2]"assert point.equals(new Point(1, 2));assert !point.equals(new Point(2, 2));

Sealed interfaces/classes (Java 17)

With Scala's language focus not just leverages, but was built on pattern matching, having an option to control your class hierarchy is essential. Hence sealed traits (interfaces) are essential part of scala. With it you can have fine grained control of the class hierarchy, and have guarantees for all possible subclasses, making pattern matching even more powerful. With the power of such features catching java's attention, it had to adapt some of the concepts as well.

With sealed interfaces it is possible to have exact control over your class subclasses that can implement your class / interface. With having switch expression coming only in java 21, with java 17 most of the gains is having all the control over your class hierarchy, enabling you to enforce design principles, and prevent unauthorized extensions.

sealed interface Animal permits Dog, Cat {}
final class Dog implements Animal {}
final class Cat implements Animal {}
final class Bird implements Animal {}

"‘Bird' is not allowed in the sealed hierarchy"

Java 21

SequencedCollection interface

Before Java 21 accessing elements in order was not just not straight forward, but some brave people might call it chaos. With some of the types not having any method to access sequential elements (like getFirst, getLast, reversed), while others having the same, on top of some these' supertypes / subtypes having methods for that, while others did not. And even the ones having methods, had separate means of calling those. In short: chaos.

With Java 21, the collections implementing sequential elements have to implement the SequentialCollection interface, making the existence of these methods obvious, and unified.

Scala Java (old) Java (new)
def doStuff(
list: List[Int],
deque: ArrayDeque[Int],
sortedSet: SortedSet[Int],
linkedHashSet: LinkedHashSet[Int]
) = {
var i = 0
// get first
i = list.head
i = deque.head
i = sortedSet.head
i = linkedHashSet.head
// get last
i = list.last
i = deque.last
i = sortedSet.last
i = linkedHashSet.last
}
void doStuff(
List<Integer> list,
Deque<Integer> deque,
SortedSet<Integer> sortedSet,
LinkedHashSet<Integer> linkedHashSet
) {
Integer i;
// get first
i = list.get(0);
i = deque.getFirst();
i = sortedSet.first();
i = linkedHashSet.iterator().next();
// get last
i = list.get(list.size() - 1);
i = deque.getLast();
i = sortedSet.last();
// i = linkedHashSet.? missing
}
void doStuff2(
List<Integer> list,
Deque<Integer> deque,
SortedSet<Integer> sortedSet,
LinkedHashSet<Integer> linkedHashSet
) {
Integer i;
// get first
i = list.getFirst();
i = deque.getFirst();
i = sortedSet.getFirst();
i = linkedHashSet.getFirst();
// get last
i = list.getLast();
i = deque.getLast();
i = sortedSet.getLast();
i = linkedHashSet.getLast();
}

Pattern matching switch

Switch expressions were a nice syntactic sugar, but functionality wise they didn't come with anything new. You could still only use primitive types, Strings, or enums, and were only able to match on the concrete value of the input. With java 21 all that changed, and you can use switch expressions with any types. In order to actually take advantage of this of course, a lot of new patterns were introduced on top of the value match.

  • null pattern

    In previous versions if the value was null inside the switch, it would always throw NullPointerException. With the new null pattern these exceptions can be caught.

  • type pattern

    You can now pattern match for the types. Just like with the pattern matching instanceof, you can use the new type without any casting necessary.

  • guarded patterns

    You can define additional conditions for you patterns. This very well corresponds with the syntax, previously seen with instanceof, where the matched arguments can already be used as a concrete type in the guards.

  • record deconstruction
    You can not only match on records similarly to the type pattern, but it is now possible to deconstruct nested structures, and match on nested types as well. You can also use the concrete types of these nested values.
    That is very powerful with deeply nested structures, where you don't need to access a very deep value by hand. + Note, that this new record deconstruction also works with the instanceof operator.
  • sealed class exhaustion

    Pattern matching switch is, where the most powerful feature of sealed classes / interfaces come to light. Because the compiler knows exactly which classes can implement a sealed class, if the input of the switch is a sealed class, you don't need to define a default case, since the compiler is able to tell if the switch is exhaustive or not. That way, previously existing bugs, of not updating a switch, when e.g. the domain changes are a thing in the past, because with this new addition the compiler will force you to.


There is no point of bringing an old example for this, since it is so different from the core, you would simply look for another approach in previous java versions.

Scala Java (new)
case class Wing(length: Int, fact: Option[String])
sealed trait Animalcase class Dog() extends Animalcase class Cat() extends Animalcase class Bird(birdType: String, wing: Wing) extends Animaldef getWingStatus(animal: Animal): String = animal match {case null => "no animal provided"case Dog() => "dogs don't have wings"case Cat() => "cats don't have wings"case Bird(, Wing(, fact)) if fact.isDefined => s"interesting fact: $"case Bird(, Wing(, Some(fact))) =>s"interesting fact: $"case Bird(birdType, Wing(length, _)) =>s"$birdType has $length long wings"}
record Wing(Integer length, Optional<String> fact) sealed interface Animal permits Dog, Cat, Bird final class Dog implements Animal final class Cat implements Animal record Bird(String birdType, Wing wing) implements Animal private String getWingStatus(Animal animal) {return switch (animal) {case null -> "no animal provided";case Dog ignored -> "dogs don't have wings";case Cat ignored -> "cats don't have wings";case Bird(String birdType, Wing(Integer length, Optional<String> fact))when fact.isPresent() -> "interesting fact: " + fact.get();case Bird(String birdType, Wing(Integer length, Optional<String> fact)) ->String.format("%s has %d long wings", birdType, length);};}

You can see, how it is pretty similar now in that exact case. However one must note, that while this is the new best thing in java, that is just the tip of the iceberg in Scala.

You can already see an example for that above, where (right before the guard pattern) the type pattern, and the literal value pattern is mixed. First the the type Bird is matched, then the record deconstructed, in a way that it will only match, if the wingfact is present.

Beyond

Unnamed variables (Java 22)

A minor improvement in developer experience is introducing unnamed variables. If you take a look at the previous example for pattern matching, you would notice, that although the example now looks pretty similar, in case of java you still had to give some name to the variables, even if you are not using them later on. With the introduction of unnamed variables you can use _ as the identifier, and you can reuse it as many times as you'd like, and not be able to reference it later. So it does not clutter up your scope, nor can it overshadow an outside variable by accident. In short it doesn't just signals the implementor's intention, but enforces it as well, making the code safer, and more obvious to read in the future.

private String getWingStatus(Animal animal) {
    return switch (animal) {
        case null -> "no animal provided";
        case Dog _ -> "dogs don't have wings";
        case Cat _ -> "cats don't have wings";
        case Bird(String _, Wing(Integer _, Optional<String> fact))
            when fact.isPresent() -> "interesting fact: " + fact.get();
        case Bird(String birdType, Wing(Integer length, Optional<String> _)) ->
            String.format("%s has %d long wings", birdType, length);
    };
}

Unnamed classes (Java 22)

With previous versions already enabling running java as a single file, or even as a script, the need for defining an exact main class is fading. This new feature leverages that thought, and lets the user create java files without extra boilerplate.

Java (old) Java (new)
public class HelloWorldJava8 {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
void main() {
System.out.println("Hello World");
}

This makes the learning curve much easier for a newcomer, but also opens the door to even easier scripting in the future.

Closing thoughts

If now, you feel like Scala is bad, because Java adopted its best features I failed. If you feel like Java is bad, because it lacks a lot of features Scala has, I also failed. If you feel like Java is excellent, because it can adopt in an environment, it's maybe not that familiar, and you feel like Scala is excellent, because it can leverage a familiar environment to the fullest, only then I have truly succeeded.

What we need to understand, is the goal of Scala, and Java are two very different things. Scala is a functional programming language, so the language and its whole ecosystem is designed around that fact, while java's main focus lies rather elsewhere.

The fact, that Java recognizes the value in a lot of functional paradigms shows the modern thinking of its maintainers, and projects a bright future ahead.

The fact, that these paradigms are existing, and also well implemented (even well enough for the old bigbrother java) shows great design from the creators of Scala.

Java and Scala, while distinct in their philosophies, have carved out a shared space within the JVM ecosystem, proving that innovation and tradition can coexist, complement, and even elevate each other, hence elevate modern software development.

]]>
<![CDATA[SBT: More than a Build Tool]]> https://blog.lunatech.com/posts/2025-02-21-sbt-more-than-a-build-tool/ https://blog.lunatech.com/posts/2025-02-21-sbt-more-than-a-build-tool/ Fri, 21 Feb 2025 00:00:00 +0000

Introduction

Scala Build Tool (SBT) is widely known for compiling, testing, and packaging Scala projects. However, its design as an extensible, programmable tool opens doors to uses beyond traditional build automation. Let's explore SBT's additional functionalities and practical applications.

SBT: A Brief Overview

What is SBT?

SBT (Scala Build Tool) is the de facto build tool for Scala projects. It's an open-source build tool written in Scala that provides a Domain-Specific Language (DSL) for describing build configurations. SBT offers interactive capabilities, incremental compilation, and continuous compilation features. It uses Scala code for build definitions, allowing developers to leverage the full power of the Scala language in their build processes.

SBT vs Traditional Build Tools

SBT is specifically designed for Scala projects, with deep integration into the Scala ecosystem. While it can handle Java code within Scala projects, it's not typically used for pure Java projects where tools like Maven and Gradle are more appropriate.

Practical Applications of SBT

Simple Configuration

SBT follows "convention over configuration" principles. For basic Scala projects, you might only need a few lines in your build.sbt file.

name := "my-project"
version := "1.0"
scalaVersion := "2.13.10"

Scala-Based Build Definition

Unlike XML-based build tools, SBT lets you write your build configuration in Scala. This means you can use variables, functions, and even complex logic in your build definitions, making them more maintainable and powerful.

val commonSettings = Seq(
  organization := "com.example",
  version := "1.0",
  scalaVersion := "2.13.10"
)

lazy val core = (project in file("core"))
  .settings(commonSettings)
  .settings(
    name := "my-core-project",
    libraryDependencies += "org.typelevel" %% "cats-core" % "2.9.0"
  )

Incremental Compilation

SBT tracks dependencies between your source files and only recompiles what's necessary. When you change a file, SBT analyzes its dependencies and recompiles only affected files, significantly reducing build times.

Library Management

Using Coursier as its dependency resolver, SBT efficiently handles library dependencies, resolving and downloading them from repositories. It manages transitive dependencies and version conflicts automatically.

Continuous Compilation

With the ~ command prefix, SBT watches your source files and automatically recompiles when changes are detected. This creates a rapid development cycle, perfect for interactive development.

// Watch and recompile
$ sbt "~compile"

// Watch and run tests
$ sbt "~test"

// Watch specific test
$ sbt "~testOnly com.example.MySpec"

Mixed Scala/Java Support

SBT seamlessly handles projects containing both Scala and Java code, automatically detecting and compiling both languages while maintaining proper dependency ordering.

Testing Framework Integration

Built-in support for major Scala testing frameworks means you can run tests directly from SBT. It integrates with ScalaTest, specs2, and ScalaCheck out of the box, with plugin support for JUnit.

Interactive Scala REPL

Launch a Scala REPL session with your project's classes and dependencies already loaded, perfect for exploring and testing code interactively.

// In your project
case class User(name: String, age: Int)
class UserService {
  def greet(user: User) = s"Hello, ${user.name}!"
}

// In the REPL (after running 'sbt console')
scala> val user = User("Alice", 25)
user: User = User(Alice,25)

scala> val service = new UserService
service: UserService = UserService@1234abcd

scala> service.greet(user)
res0: String = Hello, Alice!

Project Modularization

Break down complex projects into manageable sub-projects, each with its own dependencies and configurations, while maintaining build coordination across the entire project.

External Project Dependencies

Reference other Git repositories directly as dependencies, enabling seamless integration with external projects and custom forks of libraries.

Parallel Execution

Speed up builds and tests by running independent tasks in parallel, taking advantage of multiple CPU cores for faster build times. By default SBT runs tasks in parallel and tests in sequence. You can change this behavior by configuring the build.sbt file by either enablig test parallelism and adjusting the parallel execution settings, such as limiting the number of cores used for example.

Beyond Build Tool Features

Custom Task Creation

SBT allows you to define custom tasks for any purpose - from deploying applications to generating documentation. You can create tasks that integrate with external services, process data, or automate any development workflow.

// Define individual tasks
lazy val startDb = taskKey[Unit]("Starts the database")
startDb := {
  "docker-compose up -d postgres".!
}

lazy val runMigrations = taskKey[Unit]("Runs database migrations")
runMigrations := Def.sequential(
  startDb,                // Start database first 
  flywayClean,           // Clean database schema
  flywayMigrate          // Run Flyway migrations
).value

Development Workflow Automation

Use SBT as a complete development environment orchestrator. Create custom commands to start databases, mock services, or set up entire development environments with a single command.

// Combine previously defined tasks into a workflow
lazy val startLocalEnv = taskKey[Unit]("Start local development environment")
startLocalEnv := Def.sequential(
  runMigrations,         // Run database migrations
  (Compile / run)        // Finally start the application
).value
// Use it with:
> sbt startLocalEnv  // Executes all tasks in sequence

Code Generation

Leverage SBT's source generators to automatically create code, such as generating case classes from database schemas, creating TypeScript definitions from Scala classes, or producing API documentation.

Database Migration

Through plugins like Flyway or Slick-migration, SBT can manage database schemas and migrations, making it a powerful tool for database version control and deployment.

Using the SBT Flyway plugin:

// In plugins.sbt
addSbtPlugin("io.github.davidmweber" % "flyway-sbt" % "7.4.0")

// In build.sbt
flywayConfigFiles := Seq("flyway-e2e.conf")
> sbt flywayMigrate    // Using the SBT plugin

Documentation Generation

Beyond API docs, SBT can generate various types of documentation, from project websites to technical specifications, using plugins like sbt-site, ScalaDoc or mdoc.

A common example using ScalaDoc:

// In build.sbt
Compile / doc / scalacOptions ++= Seq(
  "-groups",
  "-doc-title", "My Project Documentation"
)
// Generate documentation with:
> sbt doc  // Creates ScalaDoc in target/scala-2.13/api/

Release Management

SBT can handle the entire release process, including version bumping, changelog generation, Git tagging, and publishing to various repositories or platforms.

Quality Analysis

Integrate with code quality tools to analyze source code, check coverage, enforce styling rules, and generate quality reports as part of your development workflow.

For example, to check code coverage in your project, first add the scoverage plugin to your project/plugins.sbt:

addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.9")

Then you can run coverage analysis:

> sbt coverage         // Enable code coverage tracking
> sbt test            // Run your tests - this collects coverage data
> sbt coverageReport  // Generate coverage report showing which code was tested

The report will be generated in target/scala-2.13/scoverage-report/ and includes:

  • HTML reports showing line-by-line coverage

  • Overall coverage statistics

  • Highlighted source code showing covered/uncovered lines

Conclusion

SBT is a powerful tool that transcends its role as a build tool, offering developers a versatile platform for managing, automating, and enhancing their development workflows. Whether you’re working on a small library or a large-scale application, SBT’s features and extensibility make it a valuable addition to the Scala ecosystem. SBT acts more as a development platform than a build tool and by understanding its capabilities and limitations, teams can leverage SBT to streamline their processes and focus on building great software.

]]>
<![CDATA[Interop Summit. Why do we only import Java libraries?]]> https://blog.lunatech.com/posts/2025-02-14-interop-summit/ https://blog.lunatech.com/posts/2025-02-14-interop-summit/ Fri, 14 Feb 2025 00:00:00 +0000

Short history of the Java platform

The Java platform, including the Java language and Java Virtual Machine (JVM), was first released around 1995. At that time, Java was the only language running on the JVM. Soon, however, other languages followed, which addressed some of Java’s shortcomings while still leveraging its runtime and the vast collection of standard and third-party libraries and allowing integration with a large amount of existing Java code.

Scala was one of the first such languages. Its development started in 2001, and the first release came out in 2004. Its main goal was to mix functional and object-oriented programming and to allow writing more concise code than Java.

Groovy was released in 2007 and introduced dynamic typing and improved scripting capabilities. Clojure also came out in 2007. As a Lisp dialect, its strengths lie in functional programming and metaprogramming.

Kotlin was one of the later major languages that entered the scene. Its implementation started in 2010, and the first stable release was in 2016. Kotlin was designed to be a nicer Java with seamless interoperability. Later, it became officially supported for Android development.

It is interesting to note that no new major languages have appeared in about 15 years now. This is probably because existing languages already cover the most important niches. Moreover, Java now includes quality-of-life features like lambdas, closures, lazy streams, records, and virtual threads, which had previously motivated the creation of new languages. Nowadays you would also need a large amount of tooling and libraries to support a new language, and probably some killer application or support and promotion from a large company.

Still, there is a large variety of less prominent languages on the JVM, including even some internal company languages. Many originally non-JVM languages have compilers that target JVM such as Jython for Python.

But given the large variety of available options, why do we only import libraries implemented in Java or in the language we are using? Well, it's probably not a huge mystery. I bet you can name several important reasons yourself. But I think it's still interesting to think about, so let's review some of them.

1. Utility libraries are implemented in each language

Small or utility libraries are simply implemented natively in each language to provide idiomatic APIs. Such implementations might be a part of the standard library or provided by third parties. A good example is JSON processing. There are a lot of third-party JSON libraries in Scala, often using typeclasses and other special Scala features. In contrast, in Kotlin JSON is supported by an official library in kotlinx.serialization . There is a similar situation with idiomatic libraries for dependency injection, web frameworks, and so on.

2. Few large unique libraries

There is a lack of genuine need to import anything large from outside. What libraries unique to Kotlin, let alone other non-Java languages, would you want to use in your Scala code? Jetpack Compose is an example of a Kotlin library occupying a unique niche. It is the de facto default GUI framework on Android. However, since it's a GUI framework you wouldn’t normally import it as a library, and you rarely see Scala code on Android anyway.

As for Scala, there are unique libraries that could be useful in Java or Kotlin code: Akka or Pekko, Spark, Gatling, or Flink. But they usually already provide bindings or APIs for Java, so you don't need their Scala APIs. Many of them are even discontinuing their Scala API, or avoiding migration to Scala 3.

3. Focus on using Java code

Java is one of the most popular languages ever created, so over its long history a lot of libraries for all kinds of purposes have been produced. This means that language authors of every JVM language make a special effort to provide an easy way to interact with Java dependencies.

The end result is that using Java code from Scala or Kotlin is fairly simple. You can call methods normally and give them arguments, extend classes and interfaces, and so on. Scala standard library supplies a +scala.jdk+ package, that contains many facilities to assist with interoperability.

Of course, this is still not always completely straightforward. One obvious point of contention is nullability. When using Java libraries from Scala you usually need to wrap nearly everything in an +Option+ to avoid null pointer exceptions. You may say that using Java libraries is not idiomatic, but even in the standard library, there are packages you may want to use occasionally: +java.nio.file+, +java.time+, Unicode support, and so on.

Here is an example of a Scala 3 extension to wrap the result of one Java method:

extension (p: Path)
  def parent: Option[Path] = Option(p.getParent)

This approach is not ideal, because if you miss any wrappers, you'll get a +NullPointerException+ at runtime.

Scala 3 has added union types, so now you can declare nullable types directly: +MyType | Null+. It also has the explicit nulls feature enabled by the +-Yexplicit-nulls+ flag. This means that all values that can have a +null+ value, including all values coming from Java, must have a nullable type. This reduces the +Option+ boilerplate when dealing with Java code, but introduces a different kind of boilerplate: null-checking the results of functions that can never return null.

To improve this the recently released Scala 3.5 has introduced flexible types inspired by Kotlin's platform types . This feature is enabled by default but can be turned off with the +-Yno-flexible-types+ flag. It means that values coming from Java can now be treated as either nullable or non-nullable. This offers the same safety guarantees as Java, so again it has reverted to the +Option+ wrapper situation, and it's possible to get null pointer exceptions again.

This development process demonstrates both how much effort forward interop receives and how hard it is to provide a good solution for some issues. Well, nulls wouldn't have been a billion-dollar mistake otherwise.

4. Reverse interoperability is complicated

Reverse interoperability refers to tools and techniques that allow developers to write code accessible from Java and potentially other JVM languages. However, it can be difficult to transparently incorporate features that are unique to a particular language. For instance, when calling Scala code from Java, implicit values must be explicitly constructed, which undermines the usability of the API.

Even in simpler cases reverse interoperability requires carefully marking your code with annotations or using some non-idiomatic constructs. For example:

case class MountainRange(mountains: List[Mountain])
object MountainRange:
  @varargs
  @targetName("of3")
  @throws[IllegalArgumentException]
  def ^^^(mountains: Mountain*): MountainRange = {
    if (mountains.size != 3) throw IllegalArgumentException("Incorrect number of mountains")
    MountainRange(mountains.toList)
  }

The annotations in this method cause the Scala compiler to produce a signature that can better interact with Java. +@varargs+ means that the argument will be a Java +Array+ instead of a Scala +Seq+, +targetName+ gives it a stable alphanumeric name instead of a technical name mangled by the compiler, and +@throws+ adds the corresponding "throws" declaration to the method signature.

Another interesting example that demonstrates how much of an afterthought reverse interop can be is trying to define an enumeration that extends +java.lang.Enum+. In Scala 3 this is straightforward:

enum Mountain extends Enum[Mountain]:
  case Everest, Kilimanjaro, MontBlanc, Fujiyama

Scala 3 compiler automatically generates calls to the +java.lang.Enum+ constructor to initialize the values. But I don't know a way to create a Java-compatible enumeration from Scala 2. Maybe it's not possible at all!

Other features reverse interoperability has to handle may include:

  • Static methods and constant values.

  • Class fields, getters, and setters, because many JVM languages try to improve their handling in some way.

  • Optional method arguments and default values. Java doesn't allow those, but most other JVM languages do.

  • Different visibility modes, such as Scala's +sealed+ or Kotlin's +internal+.

  • Concurrency primitives. Every language has something unique and barely compatible with each other. For example, Scala uses Futures or various IO libraries, Kotlin has coroutines in its standard library, and Java has recently added virtual threads.

Imagine having to support this menagerie for multiple languages, each with its own assumptions and idioms, changing and updating over time. If every language provided bindings or APIs for every other language, the complexity would explode.

5. Concerns about runtime dependencies

Using libraries from another language usually implies including that language's standard library as a runtime dependency. This slows down the build and increases distribution sizes. The effect may not be large in absolute terms, but still provides enough incentive for library authors to design their libraries to avoid unnecessary dependencies on the entire standard library of a whole language.

As a consequence of those reasons Java naturally serves as the common denominator to mediate between JVM languages.

Case study

Situations where you need to interact between non-Java languages do happen but are fairly unusual. One interesting example from our team involved configuring access to intranet repositories (without internet access) in our Gradle builds.

Let's adopt the following assumptions:

  1. We are using Kotlin for our Gradle builds, because Kotlin is statically typed and its tooling and IDE support are better than Groovy’s.

  1. Multiple teams publish their artifacts into their own repositories on the shared Artifactory. Different projects need different dependencies, so our goal is to give the project maintainers a simple way to add the repositories with the artifacts they need. We want to have an extension method on the +RepositoryHandler+, similar to the idiomatic Gradle methods such as +mavenCentral()+ or +gradlePluginPortal()+:

    repositories { // this: RepositoryHandler =>
        mavenInternal("maven-releases")
        mavenInternal("gradle-plugins")
        mavenInternal("other-team-artifacts")
    }
  1. We have a local plugin to set the repository URL and configure a way to obtain a login token from the environment:
    def extendRepositories(RepositoryHandler repositories) {
        if (repositories !instanceof ExtensionAware) return
    
        repositories.ext {
            mavenInternal = { repoName ->
                repositories.maven {
                    name = repoName
                    url = "https://artifactory.example.com/$repoName"
                    credentials {
                        token = providers.environmentVariable("ARTIFACTORY_TOKEN")
                                .orElse(providers.systemProperty("gradle.wrapperPassword"))
                                .orNull
                    }
                }
            }
        }
    }

The problem here is that Gradle can automatically execute Groovy builds, but for Kotlin builds it needs to download a special plugin, and to download the plugin without internet access, it requires the internal repository to be already configured, creating a Catch-22 type of problem. This means the repository configuration plugin above has to be implemented in Groovy.

In the Groovy build flavor, you can directly use methods defined in an extra properties extension . But Kotlin doesn't understand that approach. It can't interact with standard Groovy extension methods either. Groovy implements them by modifying Groovy metaclasses, but in Kotlin extension methods are just syntax sugar, and at runtime are implemented as normal methods taking the receiver as the first argument.

In the end, the solution was to create an intermediate plugin in Kotlin, that provides a Kotlin-style extension method. It extracts Groovy +Closure+ from the extra properties extension, casts it to the appropriate type, and calls it using Groovy API:

fun RepositoryHandler.mavenInternal(path: String) {
    ((this as ExtensionAware).extra["mavenInternal"] as Closure<*>).call(path)
}

This is still not ideal, because this helper method can't be shared between the intermediate plugin build and implementation, so it has to be copy-pasted into several places. Nevertheless, this achieves the goal of having nice repository declarations in the user-level Kotlin build.

This is an example of how convoluted interoperability can look when assumptions and idioms from different languages and libraries come in conflict with each other.

]]>