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. RefundReasonClassifier reads a free-text refund reason and hands back a category — the Domain Service that actually decides never calls it, and never even knows an LLM produced the value.
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.
The temptation with an LLM feature 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. This repo's Go implementation keeps them separate on purpose, the same as every other language port.
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, with the real implementation living 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 fraud signal covered in an earlier post, 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 every other env-dependent value in this codebase follows: 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 completely 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: this codebase 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 really did touch zero test files at all; Go's version of "almost nothing changed" comes with one small, honest asterisk.
docs/architecture/domain-service.md — the Technical Service / Domain Service split (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