Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
160645 stories
·
33 followers

Nobody Is Saying Why OpenAI and Anthropic Had Outages Today

1 Share
ChatGPT, Claude, and Grok all suffered outages at nearly the exact same time for reasons that remain murky.
Read the whole story
alvinashcraft
59 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Interactive Terminals in the Browser with Aspire and .NET!!!

1 Share
From: Isaac Levin
Duration: 7:59
Views: 4

The latest version of Aspire has brought the ability to have interactive terminals run in the browser via the Aspire Dashboard. This is great for 1-upping your console apps. Let the fun experiments begin!

Microsoft Documentation
https://aspire.dev/app-host/with-terminal/

If you want to follow me on social media, here are some links
🌐 https://www.isaacrlevin.com
🙀 https://github.com/isaacrlevin
📺 / isaacrlevin

#dotnet #csharp #terminal #aspnet #aspire

Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete

Forking Cal.com to closed source (Interview)

1 Share

This week I’m joined by Peer Richelsen, co-founder of Cal.com. What if the majority of open source repositories are already compromised and we just don’t know it yet? That’s the theory Peer brings to the table this week. We dig into how AI has flattened the knowledge graph to the point that a 16-year-old can vibe hack a power station just as easily as their mom can vibe code an iOS app, why the reporting culture that has kept open source safe all these years is collapsing under AI generated noise, Cal.com’s move to fork its own codebase and take the sensitive parts private, and the eye opening reality that shipping “$1 of AI tokens for pennies on the dollar” is now a common startup business model.

Join the discussion

Changelog++ members save 10 minutes on this episode because they made the ads disappear. Join today!

Sponsors:

  • Coder.com – Secure environments where devs and agents work in parallel. Open by design. Secure by default.
  • Buildkite – You deserve better CI. Buildkite is engineered for frontier scale and trusted by the teams setting the pace.
  • WorkOS – Auth for CLI with AuthKit from WorkOS — Bring secure browser-based login to your terminal apps using the OAuth Device Flow, with the same polished AuthKit experience plus SSO, MFA, and passkeys. Learn more at WorkOS.com and AuthKit.com
  • Fly.ioThe home of Changelog.com — Deploy your apps close to your users — global Anycast load-balancing, zero-configuration private networking, hardware isolation, and instant WireGuard VPN connections. Push-button deployments that scale to thousands of instances. Check out the speedrun to get started in minutes.

Featuring:

Show Notes:

Editorial disclosure: Adam states in the episode that he is a small seed investor in Cal.com.

Cal.com and Cal.diy

Open source, agents, and contribution workflows

AI-assisted security

Something missing or broken? PRs welcome!





Download audio: https://op3.dev/e/https://pscrb.fm/rss/p/https://cdn.changelog.com/uploads/podcast/685/the-changelog-685.mp3
Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete

Domain Events Are NOT Your Public API

1 Share

You finally did it. You stopped publishing CRUD events and started broadcasting domain events.

Instead of publishing ShipmentStatusChanged, ShipmentUpdated, or EstimatedDeliveryDateChanged, you started publishing more meaningful domain events like DeliveryAttemptFailed, ETARecalculated, and ShipmentDelayed.

And apparently that was supposed to be better, right?

Wrong.

YouTube

Check out my YouTube channel, where I post all kinds of content on Software Architecture & Design, including this video showing everything in this post.

You’re publishing domain events now rather than CRUD events. But you still feel all the pain when you need to change one of those events. You’re breaking consumers, sometimes consumers you didn’t even know existed.

That’s because your domain events are private. They’re really no different than publishing data change events if you’re exposing internal details that the rest of the system shouldn’t know about.

Not every event that occurs within a boundary or service should be something the rest of the system sees.

That’s where people get into trouble.

The general guidance often seems to be that you define domain events and then publish those events to the rest of your system.

That’s not really the case. Your public events are an API.

Domain Events Describe What Happens Inside a Boundary

Let’s use a shipment in a logistics system as an example.

We might have domain events like:

  • TruckReserved
  • CarrierAssigned
  • DispatchInstructionsSent
  • ETACalculated
  • ShipperNotified

All of these events can be useful inside the boundary.

In reality, there would probably be many more of them because what’s actually happening is a workflow. Something kicked off that workflow and multiple things happened as part of it.

But does every other part of the system care about every granular step in that workflow? Probably not. What they care about is the summary of the behavior that occurred.

What was actually happening here? We were dispatching an order.

That’s the event other parts of the system care about. OrderDispatched.

They don’t necessarily care that a carrier was assigned, dispatch instructions were sent, the ETA was calculated, and the shipper was notified.

Those are details of how the dispatch boundary accomplished its work.

Think About Events Like an HTTP API

An easy way to understand this is to compare it to something most developers are already familiar with: an HTTP API.

Let’s say you have a database. You have some database model. It doesn’t matter what type of database you’re using. You have some structure and some shape of data.

Then you may have a domain model that represents that data differently because it contains your business rules and behavior.

Now imagine you’re exposing a public HTTP API that returns JSON.

What do you return? Often it’s a composition of information.

Your database model, your domain model, and the resource model you expose through JSON are not necessarily the same thing.

If you’ve ever made them the same thing, you’ve probably experienced exactly the problem I’m talking about.

If you expose your database schema directly through your API, consumers start depending on that schema. Now when you want to change your database model, you can’t easily do it because you’ve accidentally turned an internal implementation detail into a public contract.

Events are the same thing. They’re an API. They’re a contract.

Your database model is one thing. Your domain model is another thing. Your integration events are your public contract with other parts of the system.

Those do not have to be the same.

Your Integration Model Is Its Own Model

Imagine we have a shipment.

Internally, we may have a concept like a brokered shipment. We assign a carrier, book the shipment, calculate information about the shipment, and do whatever else the domain requires.

How that shipment is persisted can look very different from the domain model.

But more importantly, neither one necessarily represents what we want to expose outside of that boundary. Other parts of the system might not care that we assigned a carrier. They care that the order was dispatched and brokered.

That’s what our integration event communicates. This is the exact same coupling problem you get when you expose your database internals through CRUD events.

If you’re leaking schema changes through events generated directly from your data model, you’re introducing coupling that’s incredibly difficult to break.

It makes your system brittle. Exposing every internal domain event can cause the exact same problem. You can’t evolve.

Integration Events Should Communicate Meaning

There can absolutely be overlap between domain events and integration events. The important part is that you’re explicitly deciding what should be communicated outside of the boundary.

You’re trying to communicate when something meaningful happened that another part of the system should know about. Not every granular step of a workflow.

Let’s go back to our shipment.

We publish OrderDispatched.

Again, that’s a summary of several things that happened internally. We selected a carrier, booked the shipment, and performed other parts of the workflow.

Other boundaries care that the order was dispatched. Later, the vehicle arrives at the shipper.

Now we might publish Arrived.

From there, something unexpected might happen. Maybe the package isn’t ready. Maybe the business is closed. Maybe they don’t need to ship it anymore.

You could say the order was canceled.

But did it really just get canceled?

If other parts of the system now need to infer what happened by looking at a series of generic status changes, you’re missing the actual business concept.

What really happened might be something like TruckOrderNotUsed.

The vehicle went to the location where it was supposed to pick up the shipment, but the shipment wasn’t available. That distinction matters.

The Business Meaning Matters

A truck order not being used has implications throughout the system.

Invoicing might care because the customer still needs to be charged a fee. We actually had a vehicle drive to the location.

Fleet management cares because that vehicle is now available for other work. It can release the vehicle so another shipment can be dispatched to it.

Reporting cares because this situation means something very different from an order simply being canceled.

Settlements care because we may still need to pay the carrier. They didn’t drive there for free.

All of those parts of the system care about what actually happened. Not because an order status changed to canceled. They care because a truck order wasn’t used. That’s a meaningful business concept.

A Domain Event and Integration Event Can Represent the Same Concept

This is also a good example of where a domain event and an integration event can overlap.

TruckOrderNotUsed might be something you care about internally within the dispatch boundary.

Other parts of the system clearly care about it too. So it’s also an integration event.

But that doesn’t mean they need to be the exact same event with the exact same schema.

Let’s say our internal domain event contains:

Maybe ReasonCode is some internal value like 789.

Now things get sketchy. Is that value meaningful outside of your boundary? If another service starts making decisions based on reason code 789, you’ve leaked an internal implementation detail into your public API.

Now that consumer is coupled to something you considered private.

The domain event and integration event can represent the exact same business concept without having the exact same schema.

It’s about the data you explicitly want to expose.

How Much Data Should an Integration Event Contain?

This leads to another common question.

How much information should be inside an integration event?

There are generally two ends of the spectrum.

On one side you have really fat events. This is often called event carried state transfer, where the event contains nearly everything about the entity when something changes.

That can quickly turn into exposing your database model through events. On the other side, you have extremely thin events that contain almost nothing except IDs. The problem with events that only contain IDs is that consumers often need more information.

What do they do? They make a synchronous call back to the producer.

Let’s say billing receives TruckOrderNotUsed, but the event doesn’t contain all the information billing needs.

Billing now makes an HTTP request back to the shipment or dispatch boundary to get more information. The same issue applies inside a monolith. It could just be an in process call rather than an HTTP request.

There’s a subtle problem with that.

When you make that synchronous call, you’re generally asking for the state right now. You’re not necessarily getting the state from when the event occurred. If the event occurred five minutes ago but the consumer didn’t process it until now, the current state could be completely different.

You’re trying to react to something that happened five minutes ago using information from right now. That’s not always what you want.

So I don’t think this is really about thin events versus fat events. Events shouldn’t contain as little information as possible just because they’re events.

They also shouldn’t contain everything under the sun.

They should contain the information consumers need to understand what happened and react to it.

Events Tell a Story

This also connects directly to versioning.

Let’s say we started with a very small TruckOrderNotUsed event:

Consumers need to understand why it wasn’t used, so maybe we add a Reason.

That reason is part of our integration contract. We define the reasons that consumers should understand rather than exposing some internal reason code.

But sometimes what looks like an event versioning problem isn’t really a versioning problem at all.

Sometimes you’ve discovered a different business concept.

For example, TruckOrderNotUsed means the truck actually went to the shipper to pick something up and the shipment wasn’t available.

That’s very different from a truck order that was canceled before the vehicle ever left.

Those are two distinct things.

If the truck went to the location, we might still owe the carrier money. If the truck never left, maybe we don’t. If the truck went to the location, maybe the customer gets charged a fee.

If it was canceled before dispatch, maybe they don’t.

Those differences matter to invoicing, settlements, reporting, and other parts of the system.

I could keep changing TruckOrderNotUsed trying to represent all of those situations. Or I can recognize that another business concept exists. Maybe that’s TruckOrderCanceledBeforeShipment.

Now our events are telling a much clearer story.

Treat Events Like a Public API

Treat your integration events like a public API. There really isn’t a difference. They’re a contract.

Defining that contract explicitly gives you the ability to evolve your system.

You don’t have to decide that some domain event you created today can never change because consumers might depend on it forever.

Your domain events are internal.

Can you decide that one of those events is incredibly stable and expose the same concept externally? Sure.

It might even have the exact same name. But it doesn’t necessarily need to contain the exact same data.

What you need internally and what you want to expose externally are two different decisions.

Your data model is not your integration model. Your domain model is not your integration model either.

You get to explicitly decide what your public API is. You get to decide what your public events are. And that distinction gives you room to change everything behind that contract without dragging every consumer along with you.

Join CodeOpinon!
Developer-level members of my Patreon or YouTube channel get access to a private Discord server to chat with other developers about Software Architecture and Design and access to source code for any working demo application I post on my blog or YouTube. Check out my Patreon or YouTube Membership for more info.

The post Domain Events Are NOT Your Public API appeared first on CodeOpinion.

Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete

Coding Agents vs. Workflows vs. Orchestration vs. Platforms: How to Architect the AI Dev Stack

1 Share

Coding agents, workflows, orchestration and platforms do four different jobs, and swapping one for another surfaces in production as an incident no test suite predicted.

Two teams point two coding agents at the same authentication module. Both agents finish and pat themselves on the back. They each have achieved clean pull requests, green test suites and a deploy nobody lost sleep over.

Then production starts handing out 401 errors in the web app. But why? Each agent tested only its own branch. They each had no idea the other existed. If the branches were merged, the agents would have seen the two branches expose a token refresh path neither test suite had heard of. Two passing builds, one broken login.

The coding ability of the agents weren’t the problem; the problem was with the developer. Both teams handed their agent a job when they should have involved them in an AI workflow.

The Four-Layer AI Dev Stack

When it comes to developing software with AI, you can informally define the process as having four layers:

  • Coding agents – Execute the work, one pass at a time
  • Workflows – Constrain a single agent’s pass and decide what counts as finished
  • Orchestration – Owns state that outlives any single pass and coordinates parallel agents
  • Platforms – Govern who may run any of it, against which repositories and data

Each layer settles a question the one below it can’t answer. Skipping a layer leaves a gap that surfaces later as an incident.

Starting at the base: 1. coding agents execute the work. 2. workflows define what counts as finished. 3. orchestration owns state coordinate parallel agents. 4. Platform governs who may run it.
The four layers of the AI development stack: coding agents at the base, then workflows, then orchestration, then platform at the top, with authority and scope flowing down the left rail and verified work flowing up the right rail.

Agents: The Workhorses

Claude Code and Cursor’s agent take “add rate limiting to the ingest endpoint,” read the repository and rewrite the code until the test goes green. They will do that all night, with a worldview one instruction wide.

That worldview lasts exactly as long as the context window. But the definition of done and the state of what is in flight have to survive the session, in a place the next teammate can open and read. Teams still ask the agent to be that memory, which is a job it was never built to do.

Let the Workflow Define ‘Finished’

A coding agent is tasked with a job. On its own, without guardrails, it can optimize for an immediate outcome even if that means it does the job wrong.

For instance, say you tell an AI agent, “Fix this unreliable rate-limit test.” That could mean that the agent’s goal becomes “make the test pass.” The agent edits the test until the run passes. If weakening the assertion is what makes the run pass, that’s probably what the agent will do. Now the test might turn green, but the underlying problem may not have been addressed.

Instead, moving the pass validation into a workflow adds a layer of accountability. Before an agent can declare success, it has to validate with the workflow.

The workflow gives that run a shape to follow instead of letting the agent improvise. The clearest example is the task list. Claude Code and Codex break a big job into verifiable subtasks and keep the list as a file committed to the repository a human can read.

The other half of the layer is the gate between steps: plan, execute, test, then stop until the checks agree. It upgrades “the agent says it’s done” to “the tests say it’s done,” the way a receipt turns “trust me” into a record. The definition of “done” lives in the workflow, written by the team before the agent starts.


Pro Tip: A task list you can read mid-run is the difference between reviewing a workflow and interviewing a transcript.


The Orchestration Layer Comes in Two Kinds

The moment two agents or two workflows are working in the same codebase, you need a system to coordinate them so they don’t conflict with each other. That system is orchestration, which ships in two flavors that share a name and little else.

General-purpose frameworks coordinate teams of specialist agents that are doing relatively independent tasks, like calling APIs or processing data. These agents don’t generally interfere with each other, so it doesn’t matter much if they are operating in the same space simultaneously. The Claude Agent SDK’s subagents are one example, farming research out to helpers. They were built for work where the shared resource is happy to be asked twice and keeps no memory of the asking. The resource is not changed by the agents accessing it. Codebases don’t forgive like that: every change is permanent and becomes the next agent’s starting point.

Software-development orchestration coordinates the moving parts a codebase runs on so it has to be much stricter. This type of orchestrator needs to keep agents’ work separate, track dependencies and validate code before declaring work finished. One common pattern gives each agent its own git worktree. The orchestrator tracks which branches exist and what each one depends on. It routes CI results back to the agent that made the change and follows every pull request through review to merge. It keeps the state of the feature in a store no agent owns, so the feature outlives the session that started it.

QuestionGeneral-purpose orchestrationSoftware-development orchestration
What do agents contend over?APIs, records, queuesBranches, files, merge history
What isolates parallel work?Session or thread stateGit worktrees and branches
What closes the feedback loop?Retry or human reviewCI results returned to the agent
What does “done” mean?The task returnsA pull request survives review

In software development, a pull request survives review or it remains a very confident diff. Any orchestrator that declares victory when the task returns is measuring enthusiasm rather than shipped software.

Push Governance Down to the Platform

Governance is what happens between “the agent asked” and “the agent acted.” When we’re talking about production work, security and compliance controls should be enforced by the platform, not left up to the agent. The questions that follow:

  • Who can initiate work, under which identity?
  • What can the agent access once the work starts?
  • Which policies and checkpoints apply to each pass?
  • What gets recorded, and who can read it later?

An agent might request access to a production database, but the platform should vet that agent just like any other system or user.

When Claude Code asks to read the production schema, the platform checks which identity is asking and which policy applies before the query runs. Those controls are the ones you already run everywhere else, pointed now at the agent.

Understand What the Tool Does—and What It Locks You Into

Every tool in the stack belongs to one of those four layers. It doesn’t matter what a vendor calls it, but what the tool actually does.

Ignore the label and ask what the tool makes reliable. “Plan, execute, test, fix” is a workflow, whatever the box says. “Durable state and mergeable parallel work” is orchestration. “Who can run what, where, with what audit trail” is a platform.

These layers also create different levels of vendor lock-in. Workflows are usually easier to replace. Orchestration, though, holds state you can’t regenerate, so it’s harder to replace.

  • Before adopting an orchestration tool, ask:Can run state and branch naming be exported as plain Git another tool could pick up?
  • Can approval policies be exported as data another tool could enforce?
  • Can telemetry be exported and queried outside the tool?
  • Does CI integration go through your source control provider’s own APIs rather than a proprietary connector?
  • Does it support open standards, like the Model Context Protocol, for connecting to other tools?

The more of these questions you can answer yes to, the easier the tool will be to replace. If the answer is no to several of them, switching vendors later may mean rebuilding parts of the way your teams develop and ship software.


Learn About Progress Forge Orchestration

Progress Forge (formerly Progress Agent Harness) is a CLI-based orchestration layer for the AI coding agents developers already use, bringing structure, shared context, controls and traceability to AI-assisted software development. Book a Progress Forge Demo today!

Request Demo

Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete

SQL Decomposition in a Nutshell

1 Share

Application developers already know what happens when one method does everything: it becomes difficult to read, test, reason over, and safely change. We use patterns like decomposition, encapsulation, and explicit dependencies because they solve those problems.

T-SQL does not give us classes, inheritance, interfaces, or polymorphism in the same way C# does, but that does not mean good software practices stop applying when logic moves into the database.

Decomposition is a good example. Breaking complex database logic into sensible, well-defined components can reduce complexity, improve readability and maintainability, and make individual pieces easier to test. These are established, respected, and proven techniques for building great software, whether the code runs in an application or inside the database.

For example: Hybrid search

In case you don't know

Hybrid search combines multiple retrieval techniques, usually full-text search and vector search, to improve the quality of search results. Rather than trusting a single ranking strategy, it retrieves candidates in different ways and then combines those results into a final ranking.

A complete hybrid search solution may involve query rewriting, embedding generation, full-text search, vector search, fusion, reranking, and response generation. Each step contributes to the overall result, but each is also naturally separable. Full-text search should be able to run without vector search. Vector search should be testable without fusion. Fusion should operate on results without needing to understand how those results were produced.

Hybrid search pipeline

Introducing separate components adds a little structural sophistication, but it can reduce the complexity of the system as a whole. That sounds contradictory, but it is not. We can measure the benefit through smaller units of code, clearer responsibilities, simpler tests, easier diagnostics, and safer changes. Decomposition adds boundaries so each part becomes easier to reason over.

What decomposition solves

Complex database logic becomes difficult to reason over when too many responsibilities accumulate in one place. A single stored procedure may begin as a straightforward query and slowly grow to validate inputs, search, rank, transform results, handle errors, and orchestrate other operations. At some point, the procedure simply understands too much.

Decomposition introduces boundaries.

Instead of asking one procedure to understand the entire architecture, divide the work into smaller units with narrower responsibilities. A procedure such as ProductSearch_FullText can focus only on full-text retrieval while another handles vector search and another handles fusion.

The result is not less capability. It is less complexity per component. Smaller units are easier to read, easier to test, easier to change, and easier to reason over. Good decomposition simplifies the design without simplifying what the system can do.

EXEC dbo.ProductSearch_FullText
    @Query = @Query,
    @TopN = @TopN;

EXEC dbo.ProductSearch_Vector
    @QueryVector = @QueryVector,
    @TopN = @TopN;

These components may eventually participate in the same hybrid search operation, but neither needs to understand how the other works.

What encapsulation solves

Decomposition creates boundaries; encapsulation makes those boundaries useful. If you are coming from application development, think about calling a method. You care about its inputs, its output, and its expected behavior. You should not need to understand every line inside it.

The same principle applies here. The caller of ProductSearch_FullText should not need to understand every FREETEXTTABLE operation, validation rule, ranking calculation, or internal implementation detail. It should understand the contract.

CREATE PROC dbo.ProductSearch_FullText
    @Query nvarchar(4000),
    @TopN int = 20
AS
BEGIN
    SELECT TOP (@TopN)
        ft.[KEY] AS ProductId,
        CAST(ROW_NUMBER() OVER (
            ORDER BY ft.[RANK] DESC
        ) AS int) AS Rank
    FROM FREETEXTTABLE(dbo.Product, *, @Query) AS ft
    ORDER BY ft.[RANK] DESC;
END;

A query goes in. Ranked products come out. How those products were found stays behind the boundary. That separation improves readability and maintainability because changes inside the boundary do not necessarily require changes outside it. We can improve the full-text implementation, add validation, or change its internal query without forcing the orchestrating procedure to change.

What statelessness solves

Statelessness makes those components easier to isolate. Here, stateless does not mean ignoring database state. Reading data is the entire point. It means avoiding hidden execution state: global temporary tables, session context, values established by a previous call, or assumptions about what another component already did.

Instead, relevant state crosses the boundary explicitly as parameters.

CREATE PROC dbo.ProductSearch_Vector
    @QueryVector vector(1536),
    @TopN int = 20
AS
BEGIN
    SELECT TOP (@TopN)
        ProductId,
        CAST(ROW_NUMBER() OVER (
            ORDER BY d.Distance
        ) AS int) AS Rank
    FROM dbo.ProductEmbedding
    CROSS APPLY
    (
        VALUES (
            VECTOR_DISTANCE(
                'cosine',
                Embedding,
                @QueryVector
            )
        )
    ) AS d(Distance)
    ORDER BY d.Distance;
END;

Everything specific to this search is explicit: the query vector and the number of candidates to return. This starts to feel a little like dependency injection in application development. Instead of a component reaching outward to discover everything it needs, its dependencies are supplied across the boundary. The benefit is not statelessness for its own sake. It is less hidden context. Dependencies become visible, executions become reproducible, failures become easier to investigate, and components become easier to test and reuse.

Notice that embedding generation is not hidden inside ProductSearch_Vector. Creating that vector can be another database component or happen in the application. Either way, vector search has a simple starting contract: give it a vector.

What does a SQL developer have?

SQL developers do not have exactly the same building blocks as application developers, but they are not without architectural tools. Stored procedures provide executable boundaries. User-defined table types provide reusable data shapes. Functions provide reusable logic. Schemas provide namespaces and security boundaries. THROW lets components define intentional failures.

Different tools, same design principles.

User-defined table types

As our hybrid search pipeline becomes decomposed, each component needs a predictable way to exchange data with the next. A user-defined table type gives that data a named, reusable shape. If you are coming from C#, think of it roughly like a small DTO for tabular data.

CREATE TYPE dbo.ProductSearchResult AS TABLE
(
    ProductId int NOT NULL,
    Rank      int NOT NULL
);

Now different parts of the pipeline can work with the same understood shape:

DECLARE @FullText dbo.ProductSearchResult;
DECLARE @Vector   dbo.ProductSearchResult;

INSERT INTO @FullText
EXEC dbo.ProductSearch_FullText @Query, @TopN;

INSERT INTO @Vector
EXEC dbo.ProductSearch_Vector @QueryVector, @TopN;

Full-text and vector search have completely different implementations, but the next stage can reason over their results in exactly the same way.

There is another subtle benefit here. FREETEXTTABLE produces its own ranking score, while vector search produces a distance. Those values mean different things and cannot sensibly be compared directly. Reciprocal Rank Fusion needs position, not the raw score, so both retrievers expose a simple 1..N rank instead.

One important distinction

A user-defined table type does not formally type a stored procedure’s result set. The procedures still need to return compatible columns. The type gives table variables and table-valued parameters a real, reusable contract inside the architecture.

Table-valued parameters are also READONLY, and user-defined table types are best treated as relatively stable contracts because changing the type later is more involved than altering a table.

User-defined functions

As the pipeline is decomposed, some logic will naturally appear in more than one component. A user-defined function lets us give that logic a name and reuse it instead of copying the same expression throughout the solution. Reciprocal Rank Fusion, for example, repeatedly calculates a score from a rank:

CREATE FUNCTION dbo.ProductSearch_RrfScore ( @Rank int )
RETURNS TABLE AS
RETURN
(
    SELECT 1.0 / (60 + @Rank) AS Score
);

Now any component can reuse that calculation:

SELECT
    r.ProductId,
    s.Score
FROM @FullText AS r
CROSS APPLY dbo.ProductSearch_RrfScore(r.Rank) AS s;

Instead of duplicating the calculation, we define it once behind a meaningful name and boundary.

The goal is not to move every expression into a function. Use a function when the logic has meaning, reuse, or enough complexity to deserve its own boundary. Inline table-valued functions are particularly useful for set-based logic because SQL Server can incorporate them into the surrounding query plan.

Schemas

Application developers use namespaces to organize related code. SQL Server schemas can serve a similar organizational purpose, but they also provide a security boundary. For example, a search architecture might eventually expose objects under a search schema. Permissions can then be granted to those capabilities without granting callers direct access to every underlying table. That is another form of encapsulation: expose what callers need while keeping implementation details behind the boundary.

Errors are part of the contract

A component’s contract includes failure too.

IF @TopN < 1
    THROW 51001, 'TopN must be greater than zero.', 1;

Custom error numbers and messages let a component fail intentionally instead of leaking an obscure error from somewhere deep inside its implementation. For an application developer, the idea should feel familiar: callers should know not only what success looks like, but which failures they are expected to handle.

Putting the pieces together

So far, each component has been independently useful. Now we can compose them.

Fusion receives the two result sets without knowing how either was generated:

CREATE PROC dbo.ProductSearch_Fuse
    @FullText dbo.ProductSearchResult READONLY,
    @Vector   dbo.ProductSearchResult READONLY,
    @TopN     int = 20
AS
BEGIN
    WITH Scores AS
    (
        SELECT r.ProductId, s.Score
        FROM @FullText AS r
        CROSS APPLY dbo.ProductSearch_RrfScore(r.Rank) AS s

        UNION ALL

        SELECT r.ProductId, s.Score
        FROM @Vector AS r
        CROSS APPLY dbo.ProductSearch_RrfScore(r.Rank) AS s
    ),
    Fused AS
    (
        SELECT
            ProductId,
            SUM(Score) AS FusionScore
        FROM Scores
        GROUP BY ProductId
    )
    SELECT TOP (@TopN)
        ProductId,
        FusionScore
    FROM Fused
    ORDER BY FusionScore DESC;
END;

UNION ALL is intentional. If a product appears in only one retriever, it still participates in fusion. If it appears in both, its two reciprocal-rank contributions are added together. Fusion is the terminal stage in this small example, so it returns FusionScore rather than the intermediate ProductSearchResult shape. Then a thin orchestration procedure describes the workflow:

CREATE PROC dbo.ProductSearch
    @Query nvarchar(4000),
    @QueryVector vector(1536),
    @TopN int = 20
AS
BEGIN
    DECLARE @FullText dbo.ProductSearchResult;
    DECLARE @Vector   dbo.ProductSearchResult;

    INSERT INTO @FullText
    EXEC dbo.ProductSearch_FullText @Query, @TopN;

    INSERT INTO @Vector
    EXEC dbo.ProductSearch_Vector @QueryVector, @TopN;

    EXEC dbo.ProductSearch_Fuse
        @FullText = @FullText,
        @Vector   = @Vector,
        @TopN     = @TopN;
END;

This is where the value of decomposition becomes visible. The orchestrator orchestrates. Full-text search handles full-text search. Vector search handles vector search. Fusion handles fusion. Each component can evolve without requiring every other component to understand how it changed.

SQL still has SQL-specific costs

This is where application patterns and database development part ways a little. A stored procedure call is not simply a C# method call, and database boundaries have engine-level costs and limitations.

INSERT...EXEC, for example, cannot be nested. If ProductSearch_FullText itself used INSERT...EXEC, the orchestrator above could not capture its result the same way. One alternative for more composable pipelines is to implement suitable leaf operations as inline table-valued functions instead of procedures.

Table variables and table-valued parameters also do not provide the same column statistics as temporary tables. For the small candidate sets common in search fusion, that may be perfectly reasonable. For much larger intermediate sets, a #temp table may produce better execution plans.

The vector example uses VECTOR_DISTANCE because exact distance makes the example easy to understand. On larger datasets, VECTOR_SEARCH with a vector index may be the better production implementation when approximate search is acceptable.

And that is precisely why the boundary matters. We can change the implementation of vector retrieval without redesigning fusion or the rest of the pipeline. These are not arguments against decomposition. They are reminders that good software design still has to respect the database engine.

Testing becomes simpler

The payoff becomes obvious when something goes wrong.

We can execute full-text search by itself:

EXEC dbo.ProductSearch_FullText
    @Query = N'running shoes',
    @TopN = 10;

No embedding generation. No vector search. No fusion. No final response generation. We can test each capability independently, which also makes debugging easier. When a hybrid result looks wrong, inspect the full-text results, inspect the vector results, test fusion independently, and find the boundary where behavior stopped matching expectations. That is much easier than reasoning over one giant stored procedure.

Don’t decompose everything

Decomposition has a cost. More components mean more objects, more contracts, and more architecture to understand. A three-line lookup does not need three stored procedures, a table type, and a function. The objective is not more components. The objective is meaningful boundaries. Decompose where responsibilities are genuinely independent, where logic deserves reuse, where testing benefits from isolation, or where one component should be free to evolve without forcing changes throughout the system.

⭐ Keep simple things simple.

The point is simpler code

Good database design is not about making SQL look like C#. It is about applying the same proven engineering principles where they make sense. Decomposition gives complex logic meaningful boundaries. Encapsulation keeps implementation details behind those boundaries. Statelessness makes dependencies explicit. SQL Server gives us stored procedures, types, functions, schemas, and intentional errors to put those ideas into practice.

The architecture may become a little more sophisticated, but each individual component becomes less complex. That is the trade. Great software is easier to read, easier to test, easier to maintain, and safer to change, whether it runs in an application or inside the database.

The post SQL Decomposition in a Nutshell appeared first on Azure SQL Dev Corner.

Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories