Four projects from my software engineering internship on Shopify’s Metafields team.
I worked across performance, API design, reliability, and repository health in a large Ruby and GraphQL codebase.
served by the cache
of compute saved−18% callback invocations
replaced by one GraphQL endpoint
Cache volume and hit-rate figures are approximate. Diagrams use made-up identifiers and timings; no merchant data or literal API responses are shown.
Software engineering intern on the Metafields team, May–August 2026.
Ruby · GraphQL · SQL · Memcached · read replicas · feature flags.
working on custom data at scale
I joined Shopify’s Metafields team as a software engineering intern. Metafields attach typed custom data to products, orders, and other resources; metaobjects group fields into reusable entries. My work touched the read path, callback lifecycle, GraphQL API, and the team’s bug backlog.
one value, attached to an owner
- owner
- product / 42
- namespace
- shipping
- key
- fragile
- typed value
- true boolean
a structured custom entry
- entry type
- care guide
- title
- keep it looking good
- instructions
- hand wash gently
- illustration
- image reference
one reader for two Checkout paths
I built a two-tier, Memcached-backed cache that served about 81% of roughly 10.8 billion daily reads across Shopify’s two largest Functions loaders, reducing mean latency from 6.5 ms to 2 ms per read.
The two loaders reached the same metafield data through different paths. The batched GraphQL loader already used a five-second cache. The direct namespace-and-key loader—used in roughly one-third of runs—went to the database every time.
I reused the existing cache design so both loaders could share authorization, keying, caching, and result reconstruction.
two paths, different costs
Repeated direct reads keep making the trip.
two callers, one reader
Both paths now run the same permission and result handling code.
follow one read through the system
I extracted the existing cache into a shared list-in/list-out reader, then routed the direct loader through it. The reader checks permissions, request memory, Memcached, and finally the replica—while returning the same ordered entities each caller already expected.
three identifiers, six stops
Change the starting cache state, then follow which identifiers make it as far as the database. The example is synthetic.
Starting state: cold cache. Press play, or step through one stop at a time.
| identifier | answer source | caller receives |
|---|---|---|
custom.colour | read replica on a cold miss | metafield entity |
custom.size | read replica on a cold miss | metafield entity |
custom.note | read replica on a cold miss | metafield entity |
five seconds, two consistency rules
Request-local memory avoids repeated work inside one request; Memcached reuses results across workers for five seconds. A normal write increments a shop-level version so new reads move to a new key immediately.
One catch: writes to metaobject-owned fields didn’t bump the shop version. The cache could hold an old value—or a “not found” result—for up to five seconds. A miss could also reach a replica that was still catching up.
edge cases that changed the implementation
A mapping bug is worse when the wrong answer gets cached. Most of the failures I had to cover came from ordinary inputs looking different in Ruby, MySQL, and the serialized cache payload.
01 collation + negative caching
MySQL can treat two spellings as equivalent while Memcached sees different strings. I mapped returned rows back using database-equivalent matching so one request could not receive a false cached absence.
02 bytes, not characters
The 10,000-byte value limit needed Ruby’s bytesize, plus boundary and multibyte tests. Oversized values receive the private absence marker.
“a” → 1 UTF-8 byte“ü” → 2 UTF-8 bytes“🌿” → 4 UTF-8 bytes03 authorization on every route
The reader rejects unreadable identifiers before lookup and checks returned rows again. A warm cache cannot become a shortcut around permissions.
04 every promise settles
I mapped partial, missing, and duplicate results back to the original batch so every GraphQL promise resolved exactly once.
rolling out each loader separately
I separated the reader extraction from the new caller adoption. The existing cached path moved first, behind a per-shop flag that started at zero percent. The direct loader received its own flag later. If one caller misbehaved, I could move only that caller back.
Tests covered partial hits, cached absences, duplicate identifiers, byte limits, permissions, legacy values, both callers, and collation in both directions. Cross-worker single-flight stayed out of scope, so the gradual ramp also protected the replica from cold-cache bursts.
callback optimization
I deduplicated metafield callback invocations within each request across six resources, including Product and Order. That removed 18% of all invocations and saved compute equivalent to 1,491 always-on machines.
metafield capabilities API
I built a GraphQL endpoint for querying metafield capabilities and extended it to expose theme-referenced sources. It deprecated all 32 manually maintained frontend implementations.
I designed the API, permission checks, and resolver, then moved consumers over so the duplicated logic could be retired.
before / 32 places to maintain
Each frontend implementation kept its own capability logic.
after / one capability contract
Consumers moved to the API and the duplicated logic could be retired.
repository cleanup
Across the metafield repositories, I fixed 17 bugs spanning null errors, orphaned metafields, and slow SQL queries. That covered 33% of the team’s small, prioritized high-severity issues and removed more than 3,500 lines of code.
high-severity issues
— ashlyn lee · end of fieldnotes.