Reusable OpenAI Service Classes
Building Reusable OpenAI SDK Service Classes
When a project only calls the OpenAI SDK from one or two places, it is common to see code like this scattered directly inside route handlers, CLI commands, or notebook cells:
from openai import OpenAI
client = OpenAI()
def summarize(text: str) -> str:
response = client.responses.create(
model="gpt-5.6-terra",
input=f"Summarize the following text in two sentences:\n\n{text}",
)
return response.output_text
This works fine for a single script. It stops working once a project grows to five, ten, or thirty places that need to call a model — a summarizer, a classifier, a chatbot handler, a moderation check, a data-extraction job. Each of these ends up creating its own client, choosing its own model string, and handling errors in a slightly different way. A service class is the standard software-engineering answer to this kind of duplication: a single class that owns the SDK client and exposes a small, purposeful set of methods that the rest of the application calls instead of touching the SDK directly.
What a Service Class Is
A service class is an object whose entire responsibility is to wrap one external dependency — here, the OpenAI SDK — behind a stable, application-specific interface. It is not a data model, and it is not a generic utility bag. It has:
- A constructor that receives (or creates) the client and any configuration it needs.
- A small number of public methods, each representing one thing the application needs to do with the model (
summarize,classify_ticket,extract_entities). - No knowledge of how those methods are called (HTTP handler, background job, CLI) — it only knows about the task.
from openai import OpenAI
class SummarizerService:
def __init__(self, client: OpenAI, model: str = "gpt-5.6-terra") -> None:
self._client = client
self._model = model
def summarize(self, text: str, *, max_sentences: int = 2) -> str:
prompt = (
f"Summarize the following text in at most {max_sentences} "
f"sentences:\n\n{text}"
)
response = self._client.responses.create(
model=self._model,
input=prompt,
)
return response.output_text
The rest of the application now depends on SummarizerService, not on openai.OpenAI or on prompt strings scattered across the codebase.
Why This Matters
Three concrete problems disappear once SDK calls are centralized in a service class:
- Single point of change. If the prompt wording, the model name, or the response-parsing logic needs to change, it changes in one file instead of in every call site that duplicated it.
- Testability. A service class can be instantiated with a fake client in tests, so application logic can be verified without making real network calls. This is the same dependency-injection pattern used for testing throughout this course — Lesson 2 builds on it directly.
- Consistent behavior. Retries, logging, default parameters, and error handling can be applied once, inside the service, instead of being re-implemented (or forgotten) at every call site.
Without this structure, a change like "switch the summarizer to a different model" or "add a retry on rate-limit errors" turns into a multi-file search-and-replace operation, which is exactly the kind of maintenance risk that service classes exist to prevent.
Designing the Interface Around the Task, Not the SDK
A common mistake is to create a service class that simply mirrors the SDK's own methods:
class BadClientWrapper:
def __init__(self, client: OpenAI) -> None:
self._client = client
def create_response(self, **kwargs):
return self._client.responses.create(**kwargs)
This adds a layer of indirection without adding any value — every caller still needs to know the SDK's parameter names, still needs to extract output_text itself, and still needs to duplicate prompt construction. A good service class method is named after what the application wants to accomplish (summarize, classify_ticket, translate) and hides the mechanics of turning that intent into an SDK call.
class TicketClassifierService:
def __init__(self, client: OpenAI, model: str = "gpt-5.6-terra") -> None:
self._client = client
self._model = model
def classify_ticket(self, ticket_text: str) -> str:
response = self._client.responses.create(
model=self._model,
input=(
"Classify the support ticket into exactly one category: "
"billing, technical, account, or other.\n\n"
f"Ticket:\n{ticket_text}\n\nCategory:"
),
)
category = response.output_text.strip().lower()
allowed = {"billing", "technical", "account", "other"}
return category if category in allowed else "other"
Notice that classify_ticket does something a raw SDK call does not: it normalizes and validates the output before returning it. This is exactly the kind of task-specific logic that belongs inside a service class, not repeated by every caller.
When to Introduce a Service Class
Introduce a service class as soon as a piece of model-calling logic is used from more than one place, or as soon as it needs to be unit tested. For a genuinely one-off script that calls the SDK exactly once and is never tested or reused, a service class is unnecessary ceremony — a plain function is enough. The decision is about reuse and testability, not about project size in lines of code.
Do not create one service class per SDK method (ResponsesService, EmbeddingsService) unless those really do correspond to independent application concerns. Instead, organize service classes around business capabilities (SummarizerService, SupportTicketService, DocumentExtractionService). This keeps the class names meaningful to someone reading the application's business logic, not just its infrastructure.
Grouping Related Operations
A service class can hold multiple related methods that share configuration and a client, as long as they belong to the same capability:
class DocumentService:
def __init__(self, client: OpenAI, model: str = "gpt-5.6-terra") -> None:
self._client = client
self._model = model
def summarize(self, text: str) -> str:
response = self._client.responses.create(
model=self._model,
input=f"Summarize this document:\n\n{text}",
)
return response.output_text
def extract_title(self, text: str) -> str:
response = self._client.responses.create(
model=self._model,
input=f"Return only the best title for this document:\n\n{text}",
)
return response.output_text.strip()
If a class starts accumulating methods for unrelated capabilities (say, ticket classification alongside document summarization), that is a sign it should be split into two service classes. A useful rule of thumb: if you cannot describe the class's responsibility in one short sentence without using "and," it is doing too much.
Testing a Service Class Without Calling the Real API
Because the service class takes its client as a constructor argument, tests can supply a fake client that returns a fixed response, without any network access:
class FakeResponse:
def __init__(self, output_text: str) -> None:
self.output_text = output_text
class FakeResponsesAPI:
def __init__(self, canned_text: str) -> None:
self._canned_text = canned_text
def create(self, **kwargs):
return FakeResponse(self._canned_text)
class FakeClient:
def __init__(self, canned_text: str) -> None:
self.responses = FakeResponsesAPI(canned_text)
def test_classify_ticket_defaults_to_other_on_unknown_category() -> None:
fake_client = FakeClient(canned_text="unknown-category")
service = TicketClassifierService(client=fake_client)
result = service.classify_ticket("My printer is on fire.")
assert result == "other"
print("PASS: classify_ticket falls back to 'other' for unrecognized output")
test_classify_ticket_defaults_to_other_on_unknown_category()
This test verifies the classifier's validation logic — the fallback to "other" when the model returns something unexpected — without depending on what the real model would actually say. The FakeClient, FakeResponsesAPI, and FakeResponse classes mimic just enough of the SDK's shape (client.responses.create(...).output_text) for the service class to work against them unmodified.
Common Mistakes
Instantiating the OpenAI client inside every method. Creating a new OpenAI() client on every call wastes connection setup and makes it impossible to inject a fake client for testing. Create the client once, in the constructor or at application startup, and pass it in.
Letting SDK-specific types leak out of the service. If summarize() returns the raw Response object instead of a plain string, every caller now needs to know about output_text and the SDK's response shape. Return plain Python types (str, dict, a small dataclass) so the rest of the application never needs to import openai at all.
One giant AIService class for the whole application. Cramming summarization, classification, translation, and embeddings into a single class produces a file that is hard to navigate and hard to test in isolation. Split by business capability instead.
Best Practices
Depend on an injected client, never a global one. Accept the client as a constructor parameter rather than importing a module-level client = OpenAI() inside the service file. This is what makes fake-client testing possible, and it is the foundation for Lesson 2's dependency injection pattern.
Keep methods named after intent, not mechanism. summarize, classify_ticket, and extract_title describe what the caller wants; they should never require the caller to know which SDK endpoint or prompt template is used underneath.
Return plain data, not SDK objects. Convert SDK response objects into plain strings, dicts, or typed models (see Lesson 3) at the boundary of the service class, so the rest of the codebase has zero dependency on the SDK's internal types.
Keep configuration (model name, defaults) as constructor parameters with sensible defaults. This lets production code use the default while tests and experiments override it explicitly, without editing the service class itself.