Java serialization and deserialization with custom object streams

Storing object state in Java once meant a single call to writeObject, and recovering it meant a symmetric readObject. For prototypes that level of simplicity is enough. Production systems in finance, healthcare, and logistics routinely need more: appending to a stream without corrupting it, swapping class versions without breaking readers, or tailoring the on-disk format so partners in Sydney or Melbourne can still open the file.

The classic ObjectOutputStream and ObjectInputStream pair covers the basics, but hard-codes assumptions that clash with long-lived archives. Default headers block naive concatenation. Custom subclasses that override writeStreamHeader and readStreamHeader give you the freedom to build files that grow over time and survive schema changes.

This guide walks through the mechanics of those custom streams and the habits that keep deserialisation reliable when records stretch back several financial years.

The Serializable foundation every custom stream relies on

Every class that travels through a custom object stream must implement java.io.Serializable. The marker is a contract with the JVM, granting permission to walk the object's graph and encode each reachable instance. Without it, writeObject throws NotSerializableException before a single byte leaves the buffer.

The serialVersionUID is the most important declaration in the class. It is a 64-bit hash that verifies sender and receiver agree on layout. When an Australian payroll system rolls out a new field for JobKeeper-style adjustments, missing or changing the UID silently invalidates every archive on disk.

Mark fields holding non-serialisable collaborators as transient. Database connections, sockets, and thread pools should never be persisted. They will be null after deserialisation, so validate them in a readObject method.

Why default streams stop being enough

A vanilla ObjectOutputStream writes its two-byte magic number on construction. Subsequent calls to append a new object fail because the second stream writes its own header in the middle of an already-structured payload. It often surfaces in Brisbane's fintech scene where a logging service writes to a rolling file across the day.

Customising the stream lets you silence that header on append. Overriding writeStreamHeader to no-op after the first call gives a clean concatenation model. The matching reader needs the same awareness so it reads the initial header once and then accepts raw objects.

Protocol variation is another motivation. AU partners exchange invoice files mixing GST-inclusive and GST-exclusive line items. A custom stream lets you encode a version byte per record so the receiver picks the right tax treatment.

Building a custom ObjectOutputStream

A minimal custom output stream extends ObjectOutputStream and replaces the header logic. The constructor delegates to super, and the protected writeStreamHeader method is overridden to no-op on subsequent instances.

public class AppendingObjectOutputStream extends ObjectOutputStream {
    private final boolean first;

    public AppendingObjectOutputStream(OutputStream out, boolean append) throws IOException {
        super(out);
        this.first = !append;
    }

    @Override
    protected void writeStreamHeader() throws IOException {
        if (first) {
            super.writeStreamHeader();
        }
    }
}

The first flag distinguishes the first writer from later ones. Pairing the class with an AppendingObjectInputStream that handles the same logic keeps the format symmetrical and the code readable on a Monday arvo after a weekend deploy.

For more elaborate cases, override writeObjectOverride and writeClassDescriptor. Australian teams exporting to legacy partners use these to strip non-portable annotations.

Reading with a matching custom ObjectInputStream

The input side mirrors the output side. A custom reader knows that the first object came with a header, and subsequent objects did not. Overriding readStreamHeader is rarely needed because ObjectInputStream already reads the header in its constructor.

Validation in resolveClass and resolveProxyClass is more useful. Restrict deserialisation to a whitelist of expected classes. Without that, gadget chains can execute during deserialisation, a real risk when processing files from external partners.

@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
    if (!allowed.contains(desc.getName())) {
        throw new InvalidClassException("Blocked: " + desc.getName());
    }
    return super.resolveClass(desc);
}

This pattern is widely used in Australian banking where ANZ or CBA send nightly batch files. The whitelist keeps the deserialiser predictable.

Handling version evolution and serialVersionUID

Changing a serialised class is a regular event. Adding a field, renaming a getter, or splitting a value object all break compatibility unless handled deliberately. Compute the UID with serialver and reference it with private static final long serialVersionUID.

When the UID changes, archives from before throw InvalidClassException. The first fix is a converter class that reads the old layout and writes the new. The second is custom readObject logic using ObjectInputStream.GetField, defaulting missing values.

The AUD default matters in code shipped from Sydney or Perth. Forgetting a currency default has caused reconciliation headaches when offshore batches assumed USD.

Fields worth marking transient

Operational checks before shipping custom streams

Files written by an older service tier in Adelaide should be readable by a newer tier in Melbourne without coordination overhead. Achieve that with a published stream format, a frozen UID per revision, and a deploy pipeline that refuses to publish a class change without a matching reader update.

Validate every class loaded by the stream against an allow list, including array types and proxies. Commit serialVersionUID values explicitly and treat changes as breaking changes requiring a migration path.

Hardening steps for any custom stream in production

These habits cover most issues Australian teams hit when serialised archives move between data centres or between acquirer and merchant in payments. Fair dinkum code ages well.