LLM · Architecture
Wiring an LLM Into a Domain Service
— Without Letting It Make the Call
An LLM is a good fraud-classification signal and a bad final judge. Let it read a free-text refund reason and hand back a category, and keep the decision in a Domain Service that never calls it and never knows an LLM produced the value. Then the model behind it can change without touching the rule.
RefundReasonClassifier described below has since been removed. The free-text reason it classified was fully controlled by the same person requesting the refund: a fraud signal that trusted the fraudster. The full story, and the principle it left behind, is in The Fraud Signal That Trusted the Fraudster. The source link at the bottom of this post now points to the last commit where the file still existed.
When an LLM goes into a business decision, where should its output stop? The temptation is to let it own the decision: classify the reason, and if it says "fraud," reject the refund right there. That collapses two different kinds of code into one: a Technical Service that talks to an external system, and a Domain Service that owns a business rule. Kept apart, the model only produces a value and the threshold stays in plain domain code. When the model behind it later moved from the Claude API to a self-hosted one, nothing outside Infrastructure changed.
The code below is from a refund flow in my example project that implements the same backend design in five languages side by side. It's the Go version; the other four keep the same split.
An LLM Is a Signal, Not a Judge
RefundReasonClassifier is a Technical Service port, an Application-layer interface defined in the minimal shape its one consumer needs. The real implementation lives in Infrastructure:
// RefundReasonClassifier is a Technical Service port (domain-service.md)
// abstracting an LLM call that classifies a refund's free-text reason.
//
// Classify has no error return by contract: on any failure (API error,
// malformed output, network error) the real implementation must log a
// warning and return a neutral fallback classification rather than
// propagating an error — a classification outage must never block a
// refund request.
type RefundReasonClassifier interface {
Classify(ctx context.Context, reason string) payment.RefundReasonClassification
}Its job ends at producing a classification. It has no opinion about what should happen next; that's not its layer's concern.
The Domain Service Still Decides
EvaluateRefundEligibility is a plain package function (no framework dependency, no DI container involved, since Go has none), and it never imports or calls the classifier. It receives an already-computed classification as a value, alongside a second, history-based fraud score, and applies its own fixed threshold:
const fraudRiskRejectionThreshold = 0.7
func EvaluateRefundEligibility(p *Payment, r *Refund, classification RefundReasonClassification, mlFraudRiskScore float64) RefundDecision {
if p.Status != StatusCompleted {
return RefundDecision{Approved: false, Reason: ErrRefundRequiresCompletedPayment.Error()}
}
if r.Amount > p.Amount {
return RefundDecision{Approved: false, Reason: ErrRefundAmountExceedsPayment.Error()}
}
if classification.Category == RefundReasonFraudSuspected && classification.FraudRiskScore >= fraudRiskRejectionThreshold {
return RefundDecision{Approved: false, Reason: ErrRefundFlaggedHighFraudRisk.Error()}
}
// ...
}Everything upstream of that function call (the LLM API request, the prompt, the retry policy) is invisible to it. The Application layer is what wires the two together:
classification := h.classifier.Classify(ctx, cmd.Reason)
// ...
decision := payment.EvaluateRefundEligibility(p, r, classification, mlFraudRiskScore)That's the whole point of the split: the fraud-rejection threshold (0.7) is a business rule, testable with plain structs and no network calls. Swap the LLM provider, the prompt, even the whole classification approach, and this function doesn't change.
Keeping Config Out of Business Code
Building the classifier's Infrastructure implementation needed a model name and an API endpoint, resolved through the same convention as every other env-dependent value in the service. Nothing outside the config package touches os.Getenv directly.
const defaultRefundClassifierModel = "qwen2.5:1.5b"
const defaultOllamaBaseURL = "http://localhost:11434"
// RefundClassifierModel returns the model id RefundReasonClassifierImpl uses,
// overridable via REFUND_CLASSIFIER_MODEL. All raw env var access for this
// feature is encapsulated here (never read directly inside
// domain/application/infrastructure code — config.md).
func RefundClassifierModel() string {
if v := os.Getenv("REFUND_CLASSIFIER_MODEL"); v != "" {
return v
}
return defaultRefundClassifierModel
}Then the Backend Changed
The classifier originally called the real Claude API via github.com/anthropics/anthropic-sdk-go. Later, the backend moved to a self-hosted Ollama model: no vendor API key, no per-request cost, running on the same infrastructure as everything else. The Infrastructure implementation swapped SDK calls for a plain net/http request to Ollama's native /api/chat endpoint, and the config changed to:
const defaultRefundClassifierModel = "qwen2.5:1.5b"
const defaultOllamaBaseURL = "http://localhost:11434"The smallest model in the family was tried first. Live-tested directly against Ollama, it misclassified a plain "charged twice, refund the duplicate" complaint as fraud_suspected with a fraud-risk score of 1.0, which would have incorrectly rejected a legitimate refund at the 0.7 threshold. 1.5B parameters was the smallest size that got this case right.
What Had to Change (Almost Nothing)
The Domain Service, the Technical Service interface, and the unit tests for both were untouched by the swap. Only the Infrastructure implementation and the two config functions changed. That's the architecture doing its job: nothing outside Infrastructure knew or cared that an LLM was involved at all, let alone which one.
One honest caveat, specific to Go: the Go implementation has no DI container, so the E2E test bootstrap wires the classifier's concrete constructor by hand. The swap needed a one-line update to that constructor call (model name and base URL) in the test setup. That's test wiring, not test logic; no assertion changed. In the languages with a DI container to do that wiring implicitly, the swap touched zero test files; Go's version of "almost nothing changed" comes with one small, honest asterisk.
docs/architecture/domain-service.md (the Technical Service / Domain Service split in my example project that implements the same backend design in five languages; it now uses a different worked example, see the update note above) · refund_reason_classifier.go (the Ollama-backed implementation as it existed, pinned to the last commit before removal) · An ML Score With Nothing to Train On (the history-based score that sat next to this classifier, and how to plug in a model with no data to train it)