Blog › ICP guides
Smalltalk developer on retainer: Pharo, Seaside, image-based development, and Squeak platform engineering on monthly retainer
September 29, 2026 · ~20 min read
On a Monday morning, a Pharo 9 order management application for a manufacturing company failed to start. The error message was terse: Error: Instance layout mismatch for SalesOrder. The application team had deployed a Metacello baseline update the previous Friday that added an approvedBy instance variable to the SalesOrder class — a change that took two minutes to write and commit to Iceberg. What they had not done was write an image migration method. Pharo’s image-based persistence serializes every live object as a slot array whose length matches the class definition at serialization time. When the production image — containing 3,912 SalesOrder instances serialized with 14 instance variable slots — was reloaded against the new code defining SalesOrder with 15 slots, Pharo’s object model detected the mismatch and refused to complete the load. The system had been down for four hours by the time the Smalltalk developer on retainer was called.
The developer had seen this pattern before. They opened the Pharo image on the development machine, navigated to the SalesOrder class in the System Browser, and added a class-side method: adaptFromPharoVersion: aVersion that iterated SalesOrder allInstances and sent approvedBy: nil to each instance, initializing the new slot to a valid default. They saved the method, evaluated it in a Workspace against a backup image snapshot, confirmed all 3,912 instances loaded without error, and promoted the patched image to production. The system came back online. The total fix — migration method plus validation on the backup image — took ninety minutes. The class change that had caused the incident had taken two.
Pharo Smalltalk fundamentals: image persistence, message syntax, and collections
Smalltalk’s defining characteristic among programming languages is image-based persistence. A running Pharo system is not a collection of source files parsed and executed on startup; it is a serialized object graph — the image — saved to a .image file and restored on every launch. Every object that exists at image save time (every class, every method compiled from source, every instance of every domain model class, every open tool window) is serialized into the image and deserialized at load time. This means that deploying a Pharo application is not “build and run the binary” but rather “load new code into the image, migrate live objects to match the new class definitions, save the image, and restart the VM.” The .changes file records the audit trail of every code change applied to the image since the .sources file was generated. The .sources file contains the compiled source code for the base system. Both accompany every image deployment.
When a class definition changes after objects have been serialized — specifically when instance variables are added, removed, or renamed — the image loader encounters objects whose serialized slot count does not match the current class definition. Pharo handles this through a migration mechanism: if the class defines a class-side method adaptFromPharoVersion: aVersion, the image loader calls it during load to perform the migration. The method’s implementation typically uses self allInstances do: [:each | ...] to iterate every live instance and initialize new slots to valid defaults or copy values from renamed slots. For instance variable renames, self allInstances do: [:each | each instVarNamed: #newName put: (each instVarNamed: #oldName)] transfers the value using Pharo’s reflective instance variable access API. For additions, self allInstances do: [:each | each approvedBy: nil] initializes the new variable with a type-correct default. The migration method must be deployed to the image before the class definition change that triggers it — which means migration methods travel ahead of structural changes in the Metacello baseline versioning sequence.
Smalltalk message syntax has three forms. Unary messages take no arguments: collection size, string reversed, number factorial. Keyword messages take one or more arguments, each preceded by a keyword ending in colon: dictionary at: key ifAbsent: [defaultValue], collection inject: 0 into: [:sum :each | sum + each], string copyReplaceAll: 'old' with: 'new'. Binary messages use operator symbols: 3 + 4, string , otherString (comma is string concatenation), collection includes: element is actually a keyword message despite its appearance. Precedence is fixed: unary binds tightest, then binary, then keyword, regardless of the operator symbols used. Parentheses override precedence explicitly. This means 3 + 4 factorial evaluates as 3 + (4 factorial) (unary factorial first), not (3 + 4) factorial. A retainer engagement reviewing Smalltalk code for correctness regularly identifies precedence assumptions that produce incorrect results when the human intuition about operator precedence from other languages is applied incorrectly to Smalltalk’s fixed three-level rule.
Block closures are first-class values in Smalltalk: [:x | x * x] is a block that accepts one argument and returns its square. Blocks are sent messages to evaluate them: [:x | x * x] value: 5 returns 25. Zero-argument blocks are sent value. Blocks with multiple arguments use the same bracket syntax: [:a :b | a + b]. Blocks capture their enclosing lexical scope — variables defined outside the block are accessible and modifiable from inside: | total | total := 0. collection do: [:each | total := total + each] accumulates into the outer variable. This lexical closure is the mechanism underlying Smalltalk’s control structures: condition ifTrue: [trueBody] ifFalse: [falseBody] passes two blocks to the boolean, which evaluates the appropriate one. collection do: [:each | ...] sends do: a block that the collection evaluates once per element. [body] whileTrue: [condition] is a loop. All control flow is message passing — there are no reserved words for if, while, or for.
Pharo’s standard collections are protocol-compatible: OrderedCollection new (dynamic array with add:/addFirst:/addLast:/remove:ifAbsent:), Dictionary new (hash map with at:put:/at:/at:ifAbsent:/includesKey:), Set new (hash set with add:/includes:/remove:ifAbsent:), Array new: n (fixed-size indexed collection with at:/at:put:), Bag new (multiset with add:/occurrencesOf:). All collections respond to the iteration protocol: do: (forEach), collect: (map), select: (filter keeping), reject: (filter removing), detect: (find first matching or raise error), detect:ifNone: (find first or evaluate default block), inject:into: (reduce with accumulator), allSatisfy: / anySatisfy: (universal/existential quantifiers). A retainer engagement covering collection architecture identifies cases where detect: raises Error: element not found in production (should be detect:ifNone: with an explicit absent-value block) and cases where collect:select: chains are creating intermediate collections unnecessarily (should be restructured as a single inject:into: or replaced with a lazy stream using Pharo’s collection streaming protocol).
Metacello, Iceberg, Seaside, and SUnit testing
Metacello is Pharo’s package manager. A Metacello baseline is a Smalltalk class that subclasses BaselineOf and defines a baseline: method that declares packages, their dependencies, and loading groups. A minimal baseline looks like: spec package: 'MyApp-Core' with: [:p | p requires: 'NeoJSON']; package: 'MyApp-Tests' with: [:p | p requires: 'MyApp-Core']. Packages are groups of classes organized into Pharo’s class category system. The baseline is stored in a git repository alongside the source code via Iceberg — Pharo’s git integration tool. Iceberg manages the mapping between the live image’s class definitions and the git-tracked source files: Iceberg remoteTypeSelector, branch management, commit authorship, and push/pull operations are performed through the Iceberg UI inside the Pharo image or via the command-line pharo --no-default-preferences Pharo.image eval "Iceberg enableMetacelloIntegration: true" scripting interface for headless CI deployments.
The most common Metacello retainer issue is dependency ordering. When two packages both depend on a third — say both MyApp-Web and MyApp-Core depend on NeoJSON — and they specify different minimum versions, Metacello loads the first-declared version and then silently overwrites it with the second. The second package’s classes may define methods that depend on API surface that exists in its minimum version but not the first package’s minimum version. The result is intermittent MessageNotUnderstood errors in production that disappear on fresh image load (because load order was different) and reappear on the next image snapshot (because snapshot preserved the broken state). The fix is explicit version pinning in the baseline using spec baseline: 'NeoJSON' with: [:s | s repository: '...' loads: 'default'; version: '2.3.0'] so both packages agree on the exact version, and structuring baseline package declarations in strict dependency order using spec requires: to force topological loading.
Seaside is a continuation-based web framework for Smalltalk. A Seaside application is composed of WAComponent subclasses, each responsible for rendering a piece of the UI and handling interactions. The renderContentOn: html method uses a canvas builder: html div class: 'container'; with: [html heading level: 1; with: 'Title'. html paragraph with: 'Body text.']. Forms are built with html form with: [html textInput on: #username of: self. html submitButton with: 'Login'. html anchor callback: [self doAction]; with: 'Click me']. The callback: block is the Seaside continuation mechanism — it captures the block and associates it with the rendered HTML element; when the user clicks the element, Seaside evaluates the block in the session’s context. Session state is maintained in WASession subclass instance variables accessible via self session. Complex multi-step workflows are implemented with WATask subclasses whose go method uses self call: to invoke sub-components and receive their return values as Smalltalk message sends: | result | result := self call: MyFormComponent new. result ifNotNil: [:value | self processValue: value].
The most common Seaside retainer issue is callback block capture. When a callback: block captures a reference to a component instance variable — html anchor callback: [self items remove: item]; with: 'Remove' — and the session is later expired and the component garbage collected, a subsequent request that triggers that callback finds self is a dead reference. The fix is capturing the minimal required primitive value, not the component reference: | itemId | itemId := item id. html anchor callback: [self removeItemById: itemId]; with: 'Remove'. The block now captures a scalar itemId value, and self (the live component, resolved at request time) handles the actual removal logic. This pattern is standard defensive Seaside practice for any callback that refers to mutable session state.
SUnit is Smalltalk’s xUnit testing framework. A TestCase subclass defines setUp (run before each test method) and tearDown (run after), plus any number of methods whose names begin with test. Assertions use self assert: expression (pass if truthy), self deny: expression (pass if falsy), self assert: actual equals: expected, self assert: [block] raises: ExceptionClass, and self assert: collection includesAll: expected. A retainer engagement covering SUnit architecture designs setUp fixtures that create minimal domain objects using factory methods rather than direct initialization, so that when domain model constructors change, only the factory methods need updating. Mock objects in Smalltalk are typically implemented by creating anonymous subclasses that override specific methods: MyServiceClass subclass: #MockMyService instanceVariableNames: 'capturedArg' ... ; methodsFor: 'service protocol' [callMethod: arg [capturedArg := arg]]. The capturedArg instance variable allows the test to verify what argument was passed without requiring a separate mocking framework.
Exception handling in Smalltalk uses block-based syntax. [body] on: Error do: [:e | e messageText] catches any Error descendant and provides the exception object. [body] ensure: [cleanup] runs the cleanup block whether or not an exception was raised — the Smalltalk equivalent of finally. [body] ifCurtailed: [cleanup] runs cleanup only if the block exits abnormally (exception or non-local return). Exception hierarchies are navigated with e signal (re-raise), e return (recover from the exception and return a default value), e retry (retry the guarded block from the beginning), and e pass (pass control to the next outer exception handler of the same type). A retainer engagement designing robust Smalltalk systems routinely adds on: Error do: guards around external service calls with structured logging in the handler block, and designs ensure: patterns for any block that acquires resources (file streams, database connections, socket connections) that must be released regardless of whether an error occurred.
How HourTab tracks Smalltalk developer retainer hours
Smalltalk retainers produce a particularly severe version of the invisible-work problem because Pharo’s live-programming environment makes the most important work — image migration design, Metacello conflict resolution, Seaside session state architecture — completely invisible at the artifact level. The client sees the application running. They do not see the ninety-minute image migration session that prevented four hours of downtime from becoming a full-day outage, because the migration method is eighteen lines of Smalltalk that initializes one instance variable to nil across 3,912 objects. The work log entry “wrote SalesOrder migration method, 1.5h” describes the duration and leaves the client unable to explain the mechanism, the risk, or the justification to their engineering leadership. The gap between what was done (one migration method) and what was prevented (production outage extending from 4 hours to 8+ hours while the team reconstructed the image from source) requires a structured explanation of how Pharo serializes object slot counts, why the slot count mismatch prevents image load, and how the migration method resolves it — context that takes five minutes to write once, per log entry, and prevents twenty minutes of client confusion per billing cycle.
HourTab gives Smalltalk developers a public retainer-hours URL they paste into the first message of every client engagement. The client opens the URL and sees the burn-down: hours purchased, hours used, hours remaining, and a work log of every session. For Smalltalk retainers specifically, the work log entries carry more information than the burn-down chart alone can convey. Each entry should name the Pharo mechanism involved (adaptFromPharoVersion: instance variable migration, Metacello baseline requires: dependency ordering, Seaside WAComponent callback: block capture, SUnit TestCase fixture setUp design, MessageTally profiler session), the specific class and method category, the diagnostic output used (Pharo inspector showing 3,912 SalesOrder instances with uninitialized approvedBy slot; Metacello conflict report showing NeoJSON version 2.1.0 and 2.3.0 loaded in conflicting order; MessageTally report showing 73% of request time in Dictionary at:ifAbsent: with 40,000 sends), the change made and the reason it was necessary (migration method required because Pharo serializes object slot counts — adding a slot without a migration method leaves deserialized instances with an uninitialized slot that raises VariableNotFound on any accessor send; baseline reordered so NeoJSON loads before both dependent packages because Metacello loads packages in declaration order, not topological order), and the before-and-after observable metric (image load errors on deploy: daily → 0 after migration method; Seaside callback errors per 1000 requests: 4.7 → 0 after session-anchored redesign; request latency at p95: 340ms → 12ms after Dictionary memoization). Entries at that level of specificity turn an invoice line into a documented systems improvement that the client can reference in engineering reviews and that provides the evidence base for retainer renewal conversations.
Smalltalk retainers are often compared to Elixir developer retainers and OCaml developer retainers in terms of the niche-language premium and the proportion of invisible advisory work. Elixir retainers involve supervisor tree design and OTP application architecture that produces no visible artifact between GenServer restarts. OCaml retainers involve module functor composition and type constraint design that compiles away entirely. Smalltalk retainers add image lifecycle management on top — a dimension of invisible work that has no direct parallel in any other language ecosystem. Clients who engage a Clojure developer on retainer for JVM-hosted Lisp work encounter a similar “the work product is the system’s stability, not a file” communication challenge, but Clojure’s JVM deployment model means there is no equivalent of the Pharo image migration incident to communicate around. HourTab’s work log makes the image lifecycle work visible to clients who have no Pharo background: the log entry names the mechanism, the risk, and the outcome in plain terms, so the client understands what the retainer prevents even if they cannot read the Smalltalk code that prevents it.
Track Smalltalk developer retainer hours without the status emails
HourTab gives Smalltalk developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: Smalltalk developer retainers
What does a Smalltalk developer on retainer typically do?
A Smalltalk developer on monthly retainer provides ongoing image lifecycle management (adaptFromPharoVersion: migration method authorship for instance variable additions and renames, .image/.changes/.sources file management, Metacello baseline versioning, Iceberg git integration), Seaside web framework development (WAComponent renderContentOn: canvas method development, html form/anchor/textInput/submitButton, WATask continuation workflow, WASession state management, WAComponent children hierarchy, session expiry handling), SUnit testing architecture (TestCase setUp/tearDown fixtures, assert:/deny:/assert:equals:/assert:raises: coverage, mock objects via protocol substitution), exception handling (on:do:/ensure:/ifCurtailed: resource cleanup chain design), and performance profiling (MessageTally profiler sessions, Cog JIT inline-cache analysis, class-variable memoization design).
What Smalltalk work is most underlogged in a retainer?
Image migration authorship (class-side adaptFromPharoVersion: method that initializes new instance variable slots across all live instances; prevents image load failure on deploy; 8–16 hours invisible in one migration method), Metacello baseline dependency conflict resolution (diagnosing two packages requiring incompatible versions of a shared dependency, causing class clobbering; restructuring baseline with explicit requires: ordering; 8–18 hours invisible in baseline reordering), and Seaside callback block capture redesign (identifying callbacks capturing live component references that become dead after session expiry; refactoring to capture primitive values; 6–14 hours invisible in block refactoring).
What are typical Smalltalk developer retainer rates?
Entry-level Smalltalk developers (1–2 years, message syntax, collections, block closures, SUnit assert:/deny:) bill at $70–$125/hr. Mid-level Smalltalk engineers (2–4 years, image persistence, Metacello baseline authorship, Iceberg, Seaside WAComponent lifecycle, on:do:/ensure: exception handling, STON/JSON serialization) bill at $115–$210/hr. Senior Smalltalk architects (4–8 years, image migration method design, Metacello conflict resolution, WATask continuation workflows, MessageTally profiling, Cog JIT analysis, Spec2 UI development, Teapot/Zinc HTTP services) bill at $165–$305/hr. Monthly retainer ranges: $2,000–$5,000/mo for advisory retainers (15–25 hrs), $7,000–$20,000/mo for full development engagements.
What should a Smalltalk developer retainer agreement include?
A Smalltalk developer retainer agreement should specify: image management scope (migration method authorship, .image snapshot strategy, Metacello baseline versioning, Iceberg git integration, headless CI deployment scripting), Seaside scope (WAComponent renderContentOn: development, html canvas building, WATask workflow, WASession state, session expiry handling), SUnit scope (TestCase fixture design, mock object substitution, Metacello load-isolation for tests), performance scope (MessageTally profiling, Cog JIT analysis, class-variable caching, on:do:/ensure: resource management), and hour logging format (Pharo version, Metacello package name, class and category, diagnostic tool used, migration method written, before/after observable metric).
How should Smalltalk developer retainer hours be logged?
Log each Smalltalk retainer session with: advisory category (Pharo image migration, Metacello baseline dependency ordering, Iceberg git integration, WAComponent renderContentOn: canvas, WATask continuation workflow, WASession state management, SUnit TestCase fixture, MessageTally profiler, Cog JIT inline-cache, BlockContext on:do:/ensure:/ifCurtailed:, STON/JSON serialization, Announcer event design), specific class and method category, diagnostic output (Pharo inspector showing uninitialized slots; Metacello conflict report showing version clobbering; MessageTally showing 73% time in Dictionary at:ifAbsent: with 40,000 sends per request), fix applied and why (adaptFromPharoVersion: migration required because Pharo serializes slot counts — adding a slot without migration leaves deserialized instances with uninitialized slot; baseline reordered because Metacello loads in declaration order, not topological order), and before/after metric (image load errors: daily → 0; callback errors per 1000 requests: 4.7 → 0; request p95 latency: 340ms → 12ms). Include Pharo version and Metacello baseline class name.