Six output formats, a 900-line render method and a config Singleton
docgen renders customer documents in six formats (PDF, HTML, CSV, XLSX, DOCX, MD). All of it lives in DocumentRenderer.render(doc, fmt), a 900-line method whose body is one if/elif chain; adding XLSX last quarter touched five files. Three of the six formats share an identical step sequence - validate, authorise, fetch data, transform, render body, sign, upload - while PDF and DOCX insert a page-count step after rendering, CSV skips signing, and individual tenants may switch off the upload step entirely. Separately, AppConfig is a Singleton: AppConfig() always returns one instance, built at import time by reading os.environ once, and 40 modules call it directly. Tests cannot point it at a temporary database, and the second test in a file sees the first test's settings. A new requirement asks for per-tenant settings, plus caching of templates fetched from a remote store that costs 200–800 ms per fetch and is requested about 50 times per render batch.
1. Decide between Template Method and Strategy for the render pipeline (or a combination) and justify the choice with concrete criteria.
Read the worked answer
Do not choose once for the whole pipeline; the two axes differ.
Axis 1 - the step sequence is stable. Validate → authorise → fetch → transform → render → post-process is fixed for every format, only individual steps vary or are disabled. That is the textbook Template Method shape: an abstract base class with a concrete render() that calls self._validate(), self._authorise(), self._fetch(), self._render_body(), self._post_process(). _render_body is abstract; _sign, _upload, _count_pages are no-op hooks overridden by PDF/DOCX. Subclasses configure a stage; they do not re-order it. Python does not enforce finality, so document render() as non-overridable and keep the hooks small.
Axis 2 - the enabled step set varies per tenant at runtime. That is Strategy: a Pipeline holding an ordered list of injected step objects, composed per tenant+format. Strategies are independently unit-testable and swappable without subclassing.
Judgement criteria: if the variation point is "which algorithm runs" and you need runtime composition or isolated testing → Strategy. If it is "one step inside a fixed algorithm", with a closed set of subclasses → Template Method. Here both hold, so use a hybrid: Template Method for the fixed phases, Strategy objects for the tenant-toggleable steps (sign, upload).
Also delete the if/elif chain with a registry - REGISTRY = {"pdf": PdfRenderer, ...} resolved by a small Factory function. Adding a format then touches exactly one line, which is the Open/Closed property the five-file change was missing.
2. Explain why the `AppConfig` Singleton is blocking the tests and the per-tenant requirement, and give the refactor.
Read the worked answer
The problems are not "Singleton" in the abstract but three concrete consequences:
- Import-time construction.
AppConfig()readsos.environonce when the module is imported, so any test that sets an environment variable after collection is ignored, and the first test's settings persist for the whole session. That is exactly the "second test sees the first test's config" symptom. - Hidden global coupling. 40 modules call
AppConfig()directly, so there is no seam to substitute a test double; the dependency is invisible in every signature. - One instance is structurally wrong. Per-tenant settings mean N configurations coexisting in one process - impossible by construction.
Refactor: make Config an ordinary class. Build one instance at the composition root (create_app(settings)) and pass it to the objects that need it, or attach it to app.config and read it from there. For per-tenant access, use a factory:
@lru_cache(maxsize=256)
def get_config(tenant_id: str) -> Config:
return Config.from_db(tenant_id)
lru_cache gives the memoisation benefit the Singleton was really after, and get_config.cache_clear() in a fixture gives test isolation for free. Make Config frozen (a dataclass with frozen=True) so no test can mutate a shared instance.
Keep a Singleton only when the resource is genuinely process-wide, immutable after construction, and lazily built under a lock - a connection pool or a logger registry. Configuration fails all three tests.
3. The template cache must be transparent to callers. Argue Proxy versus Decorator and specify the cache key and invalidation.
Read the worked answer
Both patterns wrap an object behind the same interface, so the code shape is nearly identical in Python - the difference is intent, and the intent decides the design.
Proxy when the wrapper is responsible for controlling access to the real subject, including creating it. Here the remote TemplateStore.fetch(template_id) costs 200–800 ms and the cache decides whether the subject is touched at all - a virtual proxy that owns the store's lifecycle. Callers must not know whether a fetch happened. This is the right fit.
Decorator when you are stacking responsibilities onto an object that already exists and will keep existing (logging, retry, metrics), typically several layers deep.
In practice: CachingTemplateProxy(store, ttl) implements fetch(template_id) by checking the cache, delegating on miss, and storing the result.
Key: (tenant_id, template_id, template_version). Omitting the version is the bug that ships stale documents after an editor republishes; omitting the tenant leaks one customer's template to another.
Invalidation: TTL as a backstop (60 s), plus explicit eviction when a publish event bumps template_version. Because the key contains the version, a bump makes old entries unreachable - a lazy invalidation that cannot miss.
Concurrency: 50 requests per batch will stampede on a cold key. Add single-flight - a per-key lock so one caller fetches and the rest wait - otherwise the caching proxy turns 50 sequential fetches into 50 parallel ones and the remote store sees the same load.